diff --git a/Makefile b/Makefile index 626e1aa..da55a1a 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,14 @@ docker-push: -t $(IMAGE_REPO):latest \ -t $(IMAGE_REPO):$(DATE_TAG) \ --push . + +# Database migration targets +.PHONY: sqlc-generate + +sqlc-generate: + cd internal/db && sqlc generate + +# Build the application +.PHONY: build +build: + go build -o member-console . diff --git a/README.md b/README.md index 2b1c8bc..19da070 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,33 @@ Member console application for users to create, acccess, and manage their accounts associated with the Wiki Cafe MSC (multi-stakeholder co-operative). -## Development notes: +## Database Management -- [ ] Integrate a database migration library. `internal/db/database.go`. -- [ ] Make sure viper's 'env' key will work correctly in production -- [ ] Should session-secret and csrf-secret be generated on startup instead of in the config file? They should be persisted nonetheless. Do they need to be rotated? -- [ ] Add remove trailing slash middleware if we start using more custom handlers that don't end with a slash -- [ ] Add tests - - [ ] CSRF - - [ ] Logging - - [ ] compression - - [ ] recovery - - [ ] request ID - - [ ] timeout - - [ ] secure headers and CORS -- [ ] Auth setup sanity check. Review code. - - [ ] Remove keycloak specific code - - [ ] Implement backchannel logout: When a user logs out of the application, the application should notify the identity provider to log the user out of the identity provider as well. - - [ ] Auth session timeout should match security policy - - [ ] Rate limiting on login attempts - - [ ] Subresource Integrity (SRI) for CDN assets -- [ ] Serve HTMX assets not from CDN -- [ ] Find out if timeout middleware is actually needed or if net/http handles it +This project uses [pressly/goose](https://github.com/pressly/goose) for database migrations and [sqlc](https://github.com/sqlc-dev/sqlc) for type-safe SQL code generation. + +### Database Migrations + +Migrations are embedded in the binary and run automatically on application startup. The CLI also provides migration management commands (`migrate up`, `migrate down`, `migrate status`). + +### Creating New Migrations + +```bash +# Install goose CLI tool +go install github.com/pressly/goose/v3/cmd/goose@latest + +# Create a new migration +cd internal/db/migrations +goose create your_migration_name sql +``` + +### sqlc Code Generation + +sqlc generates type-safe Go code from SQL queries and migration files. Database models and query methods are automatically generated from the migration schema and SQL files in `internal/db/queries/`. + +```bash +# Regenerate sqlc code after schema or query changes +cd internal/db && sqlc generate +``` ## Building and publishing container image @@ -68,4 +73,26 @@ Example output: e157b42a5b608882179cb4ac69c12f84 ``` -Ensure these secrets are securely stored and persisted for application use. \ No newline at end of file +Ensure these secrets are securely stored and persisted for application use. + +## Development notes: + +- [ ] Make sure viper's 'env' key will work correctly in production +- [ ] Should session-secret and csrf-secret be generated on startup instead of in the config file? They should be persisted nonetheless. Do they need to be rotated? +- [ ] Add remove trailing slash middleware if we start using more custom handlers that don't end with a slash +- [ ] Add tests + - [ ] CSRF + - [ ] Logging + - [ ] compression + - [ ] recovery + - [ ] request ID + - [ ] timeout + - [ ] secure headers and CORS +- [ ] Auth setup sanity check. Review code. + - [ ] Remove keycloak specific code + - [ ] Implement backchannel logout: When a user logs out of the application, the application should notify the identity provider to log the user out of the identity provider as well. + - [ ] Auth session timeout should match security policy + - [ ] Rate limiting on login attempts + - [ ] Subresource Integrity (SRI) for CDN assets +- [ ] Serve HTMX assets not from CDN +- [ ] Find out if timeout middleware is actually needed or if net/http handles it diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..e0c418a --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "log/slog" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/db" + "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Database migration commands", + Long: "Run database migrations up, down, or check status", +} + +var migrateUpCmd = &cobra.Command{ + Use: "up", + Short: "Run all pending migrations", + Long: "Apply all pending database migrations", + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.Default() + if logging.AppLogger != nil { + logger = logging.AppLogger + } + + dbDSN := viper.GetString("db-dsn") + config := db.DefaultDBConfig(dbDSN) + database, err := db.Connect(context.Background(), logger, config) + if err != nil { + logger.Error("failed to connect to database", slog.Any("error", err)) + return err + } + defer database.Close() + + if err := db.RunMigrations(database); err != nil { + logger.Error("failed to run migrations", slog.Any("error", err)) + return err + } + + logger.Info("migrations completed successfully") + return nil + }, +} + +var migrateDownCmd = &cobra.Command{ + Use: "down", + Short: "Rollback the last migration", + Long: "Rollback the most recently applied migration", + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.Default() + if logging.AppLogger != nil { + logger = logging.AppLogger + } + + dbDSN := viper.GetString("db-dsn") + config := db.DefaultDBConfig(dbDSN) + database, err := db.Connect(context.Background(), logger, config) + if err != nil { + logger.Error("failed to connect to database", slog.Any("error", err)) + return err + } + defer database.Close() + + if err := db.RollbackMigration(database); err != nil { + logger.Error("failed to rollback migration", slog.Any("error", err)) + return err + } + + logger.Info("migration rollback completed successfully") + return nil + }, +} + +var migrateStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show migration status", + Long: "Display the status of all migrations", + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.Default() + if logging.AppLogger != nil { + logger = logging.AppLogger + } + + dbDSN := viper.GetString("db-dsn") + config := db.DefaultDBConfig(dbDSN) + database, err := db.Connect(context.Background(), logger, config) + if err != nil { + logger.Error("failed to connect to database", slog.Any("error", err)) + return err + } + defer database.Close() + + if err := db.MigrationStatus(database); err != nil { + logger.Error("failed to get migration status", slog.Any("error", err)) + return err + } + + return nil + }, +} + +func init() { + migrateCmd.AddCommand(migrateUpCmd) + migrateCmd.AddCommand(migrateDownCmd) + migrateCmd.AddCommand(migrateStatusCmd) + rootCmd.AddCommand(migrateCmd) +} diff --git a/cmd/start.go b/cmd/start.go index 2dad03c..1e37a0d 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -35,7 +35,7 @@ var startCmd = &cobra.Command{ // Database Setup dbDSN := viper.GetString("db-dsn") dbConfig := db.DefaultDBConfig(dbDSN) - database, err := db.NewDB(ctx, logger, dbConfig) + database, err := db.ConnectAndMigrate(ctx, logger, dbConfig) if err != nil { logger.Error("failed to initialize database", slog.Any("error", err)) os.Exit(1) diff --git a/go.mod b/go.mod index d86c36b..23a951a 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,12 @@ require ( ) require ( - github.com/andybalholm/brotli v1.0.5 // indirect - github.com/klauspost/compress v1.17.2 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pressly/goose/v3 v3.24.3 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + golang.org/x/sync v0.14.0 // indirect ) require ( @@ -36,12 +40,12 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/oauth2 v0.26.0 - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 856b48a..f05aa40 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWG github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo= github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -36,6 +38,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -45,17 +49,23 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -63,6 +73,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -85,25 +97,39 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g= github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/db/database.go b/internal/db/database.go index 3d50628..700f983 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -3,7 +3,6 @@ package db import ( "context" "database/sql" - _ "embed" "errors" "fmt" "log/slog" @@ -14,9 +13,6 @@ import ( "github.com/mattn/go-sqlite3" ) -//go:embed schema.sql -var ddl string - // DBConfig holds database configuration. type DBConfig struct { DSN string // Data Source Name for SQLite @@ -126,8 +122,9 @@ func openAndConfigureDB(config *DBConfig) (*sql.DB, error) { return db, nil } -// NewDB initializes and returns a new database connection pool and runs migrations. -func NewDB(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) { +// Connect initializes and returns a new database connection pool. +// This is the basic connection function without any automatic operations. +func Connect(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) { db, err := openAndConfigureDB(config) if err != nil { return nil, err @@ -147,16 +144,26 @@ func NewDB(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, slog.Int("max_open_conns", config.MaxOpenConns), slog.Int("max_idle_conns", config.MaxIdleConns)) - // Execute schema with retry logic - err = retryOperation(ctx, logger, config, "schema execution", func() error { - _, err := db.ExecContext(ctx, ddl) - return err + return db, nil +} + +// ConnectAndMigrate initializes a database connection and automatically runs migrations. +// This is the main function for application startup. +func ConnectAndMigrate(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) { + db, err := Connect(ctx, logger, config) + if err != nil { + return nil, err + } + + // Run migrations with retry logic + err = retryOperation(ctx, logger, config, "migrations", func() error { + return RunMigrations(db) }) if err != nil { db.Close() return nil, err } - logger.Info("database schema applied") + logger.Info("database migrations applied") return db, nil } diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..989ae33 --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,56 @@ +package db + +import ( + "database/sql" + "embed" + + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +// RunMigrations runs all pending migrations +func RunMigrations(db *sql.DB) error { + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + + if err := goose.Up(db, "migrations"); err != nil { + return err + } + + return nil +} + +// RollbackMigration rolls back the last migration +func RollbackMigration(db *sql.DB) error { + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + + if err := goose.Down(db, "migrations"); err != nil { + return err + } + + return nil +} + +// MigrationStatus shows the status of all migrations +func MigrationStatus(db *sql.DB) error { + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + + if err := goose.Status(db, "migrations"); err != nil { + return err + } + + return nil +} diff --git a/internal/db/schema.sql b/internal/db/migrations/00001_init.sql similarity index 80% rename from internal/db/schema.sql rename to internal/db/migrations/00001_init.sql index d14598d..9db58a4 100644 --- a/internal/db/schema.sql +++ b/internal/db/migrations/00001_init.sql @@ -1,5 +1,4 @@ --- internal/db/schema.sql - +-- +goose Up CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, oidc_subject TEXT UNIQUE NOT NULL, @@ -31,14 +30,12 @@ CREATE TABLE payments ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); --- Create indexes for foreign keys and frequently queried columns CREATE INDEX idx_sites_user_id ON sites(user_id); CREATE INDEX idx_payments_user_id ON payments(user_id); CREATE INDEX idx_users_oidc_subject ON users(oidc_subject); CREATE INDEX idx_sites_domain ON sites(domain); CREATE INDEX idx_payments_payment_processor_id ON payments(payment_processor_id); --- Triggers to update 'updated_at' timestamps CREATE TRIGGER trigger_users_updated_at AFTER UPDATE ON users FOR EACH ROW @@ -59,3 +56,16 @@ FOR EACH ROW BEGIN UPDATE payments SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; END; + +-- +goose Down +DROP TRIGGER IF EXISTS trigger_payments_updated_at; +DROP TRIGGER IF EXISTS trigger_sites_updated_at; +DROP TRIGGER IF EXISTS trigger_users_updated_at; +DROP INDEX IF EXISTS idx_payments_payment_processor_id; +DROP INDEX IF EXISTS idx_sites_domain; +DROP INDEX IF EXISTS idx_users_oidc_subject; +DROP INDEX IF EXISTS idx_payments_user_id; +DROP INDEX IF EXISTS idx_sites_user_id; +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS sites; +DROP TABLE IF EXISTS users; diff --git a/internal/db/querier.go b/internal/db/querier.go index d7b219b..6ca377f 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -12,12 +12,6 @@ type Querier interface { CreatePayment(ctx context.Context, arg CreatePaymentParams) (Payment, error) CreateSite(ctx context.Context, arg CreateSiteParams) (Site, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - // THIS IS NOT USED YET - // -- name: UpdateSiteDomain :one - // UPDATE sites - // SET domain = ? - // WHERE id = ? - // RETURNING *; DeleteSite(ctx context.Context, id int64) error DeleteUser(ctx context.Context, id int64) error GetPaymentByID(ctx context.Context, id int64) (Payment, error) diff --git a/internal/db/queries/sites.sql b/internal/db/queries/sites.sql index 7ffdd87..8e4999b 100644 --- a/internal/db/queries/sites.sql +++ b/internal/db/queries/sites.sql @@ -16,13 +16,6 @@ ORDER BY created_at DESC; SELECT * FROM sites WHERE domain = ?; --- THIS IS NOT USED YET --- -- name: UpdateSiteDomain :one --- UPDATE sites --- SET domain = ? --- WHERE id = ? --- RETURNING *; - -- name: DeleteSite :exec DELETE FROM sites WHERE id = ?; diff --git a/internal/db/sites.sql.go b/internal/db/sites.sql.go index 0614ffa..61f79b1 100644 --- a/internal/db/sites.sql.go +++ b/internal/db/sites.sql.go @@ -34,17 +34,10 @@ func (q *Queries) CreateSite(ctx context.Context, arg CreateSiteParams) (Site, e } const deleteSite = `-- name: DeleteSite :exec - DELETE FROM sites WHERE id = ? ` -// THIS IS NOT USED YET -// -- name: UpdateSiteDomain :one -// UPDATE sites -// SET domain = ? -// WHERE id = ? -// RETURNING *; func (q *Queries) DeleteSite(ctx context.Context, id int64) error { _, err := q.db.ExecContext(ctx, deleteSite, id) return err diff --git a/internal/db/sqlc.yaml b/internal/db/sqlc.yaml index 647a5da..0ccb910 100644 --- a/internal/db/sqlc.yaml +++ b/internal/db/sqlc.yaml @@ -2,7 +2,7 @@ version: "2" sql: - engine: "sqlite" - schema: "schema.sql" + schema: "migrations/" queries: "queries/" gen: go: