diff --git a/.vscode/settings.json b/.vscode/settings.json index 53be889..bc9d214 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,6 +54,7 @@ } }, "conventionalCommits.scopes": [ - "spectypes" + "spectypes", + "dbmanager" ] -} +} \ No newline at end of file diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go index 4d53334..9eeca2e 100644 --- a/cmd/testserver/main.go +++ b/cmd/testserver/main.go @@ -1,12 +1,14 @@ package main import ( + "context" "fmt" "log" "os" "time" "github.com/bitechdev/ResolveSpec/pkg/config" + "github.com/bitechdev/ResolveSpec/pkg/dbmanager" "github.com/bitechdev/ResolveSpec/pkg/logger" "github.com/bitechdev/ResolveSpec/pkg/modelregistry" "github.com/bitechdev/ResolveSpec/pkg/server" @@ -15,7 +17,6 @@ import ( "github.com/bitechdev/ResolveSpec/pkg/resolvespec" "github.com/gorilla/mux" - "github.com/glebarez/sqlite" "gorm.io/gorm" gormlog "gorm.io/gorm/logger" ) @@ -40,12 +41,14 @@ func main() { logger.Info("ResolveSpec test server starting") logger.Info("Configuration loaded - Server will listen on: %s", cfg.Server.Addr) - // Initialize database - db, err := initDB(cfg) + // Initialize database manager + ctx := context.Background() + dbMgr, db, err := initDB(ctx, cfg) if err != nil { logger.Error("Failed to initialize database: %+v", err) os.Exit(1) } + defer dbMgr.Close() // Create router r := mux.NewRouter() @@ -117,7 +120,7 @@ func main() { } } -func initDB(cfg *config.Config) (*gorm.DB, error) { +func initDB(ctx context.Context, cfg *config.Config) (dbmanager.Manager, *gorm.DB, error) { // Configure GORM logger based on config logLevel := gormlog.Info if !cfg.Logger.Dev { @@ -135,25 +138,41 @@ func initDB(cfg *config.Config) (*gorm.DB, error) { }, ) - // Use database URL from config if available, otherwise use default SQLite - dbURL := cfg.Database.URL - if dbURL == "" { - dbURL = "test.db" + // Create database manager from config + mgr, err := dbmanager.NewManager(dbmanager.FromConfig(cfg.DBManager)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create database manager: %w", err) } - // Create SQLite database - db, err := gorm.Open(sqlite.Open(dbURL), &gorm.Config{Logger: newLogger, FullSaveAssociations: false}) - if err != nil { - return nil, err + // Connect all databases + if err := mgr.Connect(ctx); err != nil { + return nil, nil, fmt.Errorf("failed to connect databases: %w", err) } + // Get default connection + conn, err := mgr.GetDefault() + if err != nil { + mgr.Close() + return nil, nil, fmt.Errorf("failed to get default connection: %w", err) + } + + // Get GORM database + gormDB, err := conn.GORM() + if err != nil { + mgr.Close() + return nil, nil, fmt.Errorf("failed to get GORM database: %w", err) + } + + // Update GORM logger + gormDB.Logger = newLogger + modelList := testmodels.GetTestModels() // Auto migrate schemas - err = db.AutoMigrate(modelList...) - if err != nil { - return nil, err + if err := gormDB.AutoMigrate(modelList...); err != nil { + mgr.Close() + return nil, nil, fmt.Errorf("failed to auto migrate: %w", err) } - return db, nil + return mgr, gormDB, nil } diff --git a/config.yaml b/config.yaml index 892d822..eab7c67 100644 --- a/config.yaml +++ b/config.yaml @@ -37,5 +37,25 @@ cors: tracing: enabled: false -database: - url: "" # Empty means use default SQLite (test.db) +# Database Manager Configuration +dbmanager: + default_connection: "primary" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 30m + conn_max_idle_time: 5m + retry_attempts: 3 + retry_delay: 1s + health_check_interval: 30s + enable_auto_reconnect: true + + connections: + primary: + name: "primary" + type: "sqlite" + filepath: "test.db" + default_orm: "gorm" + enable_logging: true + enable_metrics: false + connect_timeout: 10s + query_timeout: 30s diff --git a/go.mod b/go.mod index dddd7a5..21c8094 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/jackc/pgx/v5 v5.6.0 github.com/klauspost/compress v1.18.0 github.com/mattn/go-sqlite3 v1.14.32 + github.com/microsoft/go-mssqldb v1.9.5 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/nats-io/nats.go v1.48.0 github.com/prometheus/client_golang v1.23.2 @@ -26,9 +27,12 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/uptrace/bun v1.2.16 + github.com/uptrace/bun/dialect/mssqldialect v1.2.16 + github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 github.com/uptrace/bun/driver/sqliteshim v1.2.16 github.com/uptrace/bunrouter v1.0.23 + go.mongodb.org/mongo-driver v1.17.6 go.opentelemetry.io/otel v1.38.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 @@ -39,6 +43,7 @@ require ( golang.org/x/time v0.14.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 + gorm.io/driver/sqlserver v1.6.3 gorm.io/gorm v1.30.0 ) @@ -70,6 +75,9 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -86,6 +94,7 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.11 // indirect @@ -105,6 +114,7 @@ require ( github.com/rs/xid v1.4.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -119,6 +129,10 @@ require ( github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect @@ -128,6 +142,7 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.45.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index 7c0571a..250fc22 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,32 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -32,6 +56,7 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -41,6 +66,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -76,21 +103,37 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -101,6 +144,12 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= 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/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -110,8 +159,11 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= 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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -124,6 +176,9 @@ 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/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= +github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= +github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -142,6 +197,10 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= github.com/mochi-mqtt/server/v2 v2.7.9/go.mod h1:lZD3j35AVNqJL5cezlnSkuG05c0FCHSsfAKSPBOSbqc= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -162,6 +221,10 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -182,6 +245,8 @@ github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5i github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= @@ -190,6 +255,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -203,10 +270,18 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -228,6 +303,10 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun/dialect/mssqldialect v1.2.16 h1:rKv0cKPNBviXadB/+2Y/UedA/c1JnwGzUWZkdN5FdSQ= +github.com/uptrace/bun/dialect/mssqldialect v1.2.16/go.mod h1:J5U7tGKWDsx2Q7MwDZF2417jCdpD6yD/ZMFJcCR80bk= +github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4= +github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo= github.com/uptrace/bun/dialect/sqlitedialect v1.2.16 h1:6wVAiYLj1pMibRthGwy4wDLa3D5AQo32Y8rvwPd8CQ0= github.com/uptrace/bun/dialect/sqlitedialect v1.2.16/go.mod h1:Z7+5qK8CGZkDQiPMu+LSdVuDuR1I5jcwtkB1Pi3F82E= github.com/uptrace/bun/driver/sqliteshim v1.2.16 h1:M6Dh5kkDWFbUWBrOsIE1g1zdZ5JbSytTD4piFRBOUAI= @@ -240,8 +319,19 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/warkanum/bun v1.2.17 h1:HP8eTuKSNcqMDhhIPFxEbgV/yct6RR0/c3qHH3PNZUA= github.com/warkanum/bun v1.2.17/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= @@ -274,33 +364,127 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -315,6 +499,10 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/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= @@ -322,6 +510,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI= +gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/pkg/common/adapters/database/utils.go b/pkg/common/adapters/database/utils.go index 4caec07..16bc82f 100644 --- a/pkg/common/adapters/database/utils.go +++ b/pkg/common/adapters/database/utils.go @@ -1,7 +1,16 @@ package database import ( + "database/sql" "strings" + + "github.com/uptrace/bun/dialect/mssqldialect" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/dialect/sqlitedialect" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/driver/sqlserver" + "gorm.io/gorm" ) // parseTableName splits a table name that may contain schema into separate schema and table @@ -14,3 +23,39 @@ func parseTableName(fullTableName string) (schema, table string) { } return "", fullTableName } + +// GetPostgresDialect returns a Bun PostgreSQL dialect +func GetPostgresDialect() *pgdialect.Dialect { + return pgdialect.New() +} + +// GetSQLiteDialect returns a Bun SQLite dialect +func GetSQLiteDialect() *sqlitedialect.Dialect { + return sqlitedialect.New() +} + +// GetMSSQLDialect returns a Bun MSSQL dialect +func GetMSSQLDialect() *mssqldialect.Dialect { + return mssqldialect.New() +} + +// GetPostgresDialector returns a GORM PostgreSQL dialector +func GetPostgresDialector(db *sql.DB) gorm.Dialector { + return postgres.New(postgres.Config{ + Conn: db, + }) +} + +// GetSQLiteDialector returns a GORM SQLite dialector +func GetSQLiteDialector(db *sql.DB) gorm.Dialector { + return sqlite.Dialector{ + Conn: db, + } +} + +// GetMSSQLDialector returns a GORM MSSQL dialector +func GetMSSQLDialector(db *sql.DB) gorm.Dialector { + return sqlserver.New(sqlserver.Config{ + Conn: db, + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c18231..6798101 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,8 +11,8 @@ type Config struct { ErrorTracking ErrorTrackingConfig `mapstructure:"error_tracking"` Middleware MiddlewareConfig `mapstructure:"middleware"` CORS CORSConfig `mapstructure:"cors"` - Database DatabaseConfig `mapstructure:"database"` EventBroker EventBrokerConfig `mapstructure:"event_broker"` + DBManager DBManagerConfig `mapstructure:"dbmanager"` } // ServerConfig holds server-related configuration @@ -76,11 +76,6 @@ type CORSConfig struct { MaxAge int `mapstructure:"max_age"` } -// DatabaseConfig holds database configuration (primarily for testing) -type DatabaseConfig struct { - URL string `mapstructure:"url"` -} - // ErrorTrackingConfig holds error tracking configuration type ErrorTrackingConfig struct { Enabled bool `mapstructure:"enabled"` diff --git a/pkg/config/dbmanager.go b/pkg/config/dbmanager.go new file mode 100644 index 0000000..681f93c --- /dev/null +++ b/pkg/config/dbmanager.go @@ -0,0 +1,107 @@ +package config + +import ( + "fmt" + "time" +) + +// DBManagerConfig contains configuration for the database connection manager +type DBManagerConfig struct { + // DefaultConnection is the name of the default connection to use + DefaultConnection string `mapstructure:"default_connection"` + + // Connections is a map of connection name to connection configuration + Connections map[string]DBConnectionConfig `mapstructure:"connections"` + + // Global connection pool defaults + MaxOpenConns int `mapstructure:"max_open_conns"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` + + // Retry policy + RetryAttempts int `mapstructure:"retry_attempts"` + RetryDelay time.Duration `mapstructure:"retry_delay"` + RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"` + + // Health checks + HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` + EnableAutoReconnect bool `mapstructure:"enable_auto_reconnect"` +} + +// DBConnectionConfig defines configuration for a single database connection +type DBConnectionConfig struct { + // Name is the unique name of this connection + Name string `mapstructure:"name"` + + // Type is the database type (postgres, sqlite, mssql, mongodb) + Type string `mapstructure:"type"` + + // DSN is the complete Data Source Name / connection string + // If provided, this takes precedence over individual connection parameters + DSN string `mapstructure:"dsn"` + + // Connection parameters (used if DSN is not provided) + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + + // PostgreSQL/MSSQL specific + SSLMode string `mapstructure:"sslmode"` // disable, require, verify-ca, verify-full + Schema string `mapstructure:"schema"` // Default schema + + // SQLite specific + FilePath string `mapstructure:"filepath"` + + // MongoDB specific + AuthSource string `mapstructure:"auth_source"` + ReplicaSet string `mapstructure:"replica_set"` + ReadPreference string `mapstructure:"read_preference"` // primary, secondary, etc. + + // Connection pool settings (overrides global defaults) + MaxOpenConns *int `mapstructure:"max_open_conns"` + MaxIdleConns *int `mapstructure:"max_idle_conns"` + ConnMaxLifetime *time.Duration `mapstructure:"conn_max_lifetime"` + ConnMaxIdleTime *time.Duration `mapstructure:"conn_max_idle_time"` + + // Timeouts + ConnectTimeout time.Duration `mapstructure:"connect_timeout"` + QueryTimeout time.Duration `mapstructure:"query_timeout"` + + // Features + EnableTracing bool `mapstructure:"enable_tracing"` + EnableMetrics bool `mapstructure:"enable_metrics"` + EnableLogging bool `mapstructure:"enable_logging"` + + // DefaultORM specifies which ORM to use for the Database() method + // Options: "bun", "gorm", "native" + DefaultORM string `mapstructure:"default_orm"` + + // Tags for organization and filtering + Tags map[string]string `mapstructure:"tags"` +} + +// ToManagerConfig converts config.DBManagerConfig to dbmanager.ManagerConfig +// This is used to avoid circular dependencies +func (c *DBManagerConfig) ToManagerConfig() interface{} { + // This will be implemented in the dbmanager package + // to convert from config types to dbmanager types + return c +} + +// Validate validates the DBManager configuration +func (c *DBManagerConfig) Validate() error { + if len(c.Connections) == 0 { + return fmt.Errorf("at least one connection must be configured") + } + + if c.DefaultConnection != "" { + if _, ok := c.Connections[c.DefaultConnection]; !ok { + return fmt.Errorf("default connection '%s' not found in connections", c.DefaultConnection) + } + } + + return nil +} diff --git a/pkg/dbmanager/README.md b/pkg/dbmanager/README.md new file mode 100644 index 0000000..bb69dd2 --- /dev/null +++ b/pkg/dbmanager/README.md @@ -0,0 +1,531 @@ +# Database Connection Manager (dbmanager) + +A comprehensive database connection manager for Go that provides centralized management of multiple named database connections with support for PostgreSQL, SQLite, MSSQL, and MongoDB. + +## Features + +- **Multiple Named Connections**: Manage multiple database connections with names like `primary`, `analytics`, `cache-db` +- **Multi-Database Support**: PostgreSQL, SQLite, Microsoft SQL Server, and MongoDB +- **Multi-ORM Access**: Each SQL connection provides access through: + - **Bun ORM** - Modern, lightweight ORM + - **GORM** - Popular Go ORM + - **Native** - Standard library `*sql.DB` + - All three share the same underlying connection pool +- **Configuration-Driven**: YAML configuration with Viper integration +- **Production-Ready Features**: + - Automatic health checks and reconnection + - Prometheus metrics + - Connection pooling with configurable limits + - Retry logic with exponential backoff + - Graceful shutdown + - OpenTelemetry tracing support + +## Installation + +```bash +go get github.com/bitechdev/ResolveSpec/pkg/dbmanager +``` + +## Quick Start + +### 1. Configuration + +Create a configuration file (e.g., `config.yaml`): + +```yaml +dbmanager: + default_connection: "primary" + + # Global connection pool defaults + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 30m + conn_max_idle_time: 5m + + # Retry configuration + retry_attempts: 3 + retry_delay: 1s + retry_max_delay: 10s + + # Health checks + health_check_interval: 30s + enable_auto_reconnect: true + + connections: + # Primary PostgreSQL connection + primary: + type: postgres + host: localhost + port: 5432 + user: myuser + password: mypassword + database: myapp + sslmode: disable + default_orm: bun + enable_metrics: true + enable_tracing: true + enable_logging: true + + # Read replica for analytics + analytics: + type: postgres + dsn: "postgres://readonly:pass@analytics:5432/analytics" + default_orm: bun + enable_metrics: true + + # SQLite cache + cache-db: + type: sqlite + filepath: /var/lib/app/cache.db + max_open_conns: 1 + + # MongoDB for documents + documents: + type: mongodb + host: localhost + port: 27017 + database: documents + user: mongouser + password: mongopass + auth_source: admin + enable_metrics: true +``` + +### 2. Initialize Manager + +```go +package main + +import ( + "context" + "log" + + "github.com/bitechdev/ResolveSpec/pkg/config" + "github.com/bitechdev/ResolveSpec/pkg/dbmanager" +) + +func main() { + // Load configuration + cfgMgr := config.NewManager() + if err := cfgMgr.Load(); err != nil { + log.Fatal(err) + } + cfg, _ := cfgMgr.GetConfig() + + // Create database manager + mgr, err := dbmanager.NewManager(cfg.DBManager) + if err != nil { + log.Fatal(err) + } + defer mgr.Close() + + // Connect all databases + ctx := context.Background() + if err := mgr.Connect(ctx); err != nil { + log.Fatal(err) + } + + // Your application code here... +} +``` + +### 3. Use Database Connections + +#### Get Default Database + +```go +// Get the default database (as configured common.Database interface) +db, err := mgr.GetDefaultDatabase() +if err != nil { + log.Fatal(err) +} + +// Use it with any query +var users []User +err = db.NewSelect(). + Model(&users). + Where("active = ?", true). + Scan(ctx, &users) +``` + +#### Get Named Connection with Specific ORM + +```go +// Get primary connection +primary, err := mgr.Get("primary") +if err != nil { + log.Fatal(err) +} + +// Use with Bun +bunDB, err := primary.Bun() +if err != nil { + log.Fatal(err) +} +err = bunDB.NewSelect().Model(&users).Scan(ctx) + +// Use with GORM (same underlying connection!) +gormDB, err := primary.GORM() +if err != nil { + log.Fatal(err) +} +gormDB.Where("active = ?", true).Find(&users) + +// Use native *sql.DB +nativeDB, err := primary.Native() +if err != nil { + log.Fatal(err) +} +rows, err := nativeDB.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true) +``` + +#### Use MongoDB + +```go +// Get MongoDB connection +docs, err := mgr.Get("documents") +if err != nil { + log.Fatal(err) +} + +mongoClient, err := docs.MongoDB() +if err != nil { + log.Fatal(err) +} + +collection := mongoClient.Database("documents").Collection("articles") +// Use MongoDB driver... +``` + +#### Change Default Database + +```go +// Switch to analytics database as default +err := mgr.SetDefaultDatabase("analytics") +if err != nil { + log.Fatal(err) +} + +// Now GetDefaultDatabase() returns the analytics connection +db, _ := mgr.GetDefaultDatabase() +``` + +## Configuration Reference + +### Manager Configuration + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `default_connection` | string | "" | Name of the default connection | +| `connections` | map | {} | Map of connection name to ConnectionConfig | +| `max_open_conns` | int | 25 | Global default for max open connections | +| `max_idle_conns` | int | 5 | Global default for max idle connections | +| `conn_max_lifetime` | duration | 30m | Global default for connection max lifetime | +| `conn_max_idle_time` | duration | 5m | Global default for connection max idle time | +| `retry_attempts` | int | 3 | Number of connection retry attempts | +| `retry_delay` | duration | 1s | Initial retry delay | +| `retry_max_delay` | duration | 10s | Maximum retry delay | +| `health_check_interval` | duration | 30s | Interval between health checks | +| `enable_auto_reconnect` | bool | true | Auto-reconnect on health check failure | + +### Connection Configuration + +| Field | Type | Description | +|-------|------|-------------| +| `name` | string | Unique connection name | +| `type` | string | Database type: `postgres`, `sqlite`, `mssql`, `mongodb` | +| `dsn` | string | Complete connection string (overrides individual params) | +| `host` | string | Database host | +| `port` | int | Database port | +| `user` | string | Username | +| `password` | string | Password | +| `database` | string | Database name | +| `sslmode` | string | SSL mode (postgres/mssql): `disable`, `require`, etc. | +| `schema` | string | Default schema (postgres/mssql) | +| `filepath` | string | File path (sqlite only) | +| `auth_source` | string | Auth source (mongodb) | +| `replica_set` | string | Replica set name (mongodb) | +| `read_preference` | string | Read preference (mongodb): `primary`, `secondary`, etc. | +| `max_open_conns` | int | Override global max open connections | +| `max_idle_conns` | int | Override global max idle connections | +| `conn_max_lifetime` | duration | Override global connection max lifetime | +| `conn_max_idle_time` | duration | Override global connection max idle time | +| `connect_timeout` | duration | Connection timeout (default: 10s) | +| `query_timeout` | duration | Query timeout (default: 30s) | +| `enable_tracing` | bool | Enable OpenTelemetry tracing | +| `enable_metrics` | bool | Enable Prometheus metrics | +| `enable_logging` | bool | Enable connection logging | +| `default_orm` | string | Default ORM for Database(): `bun`, `gorm`, `native` | +| `tags` | map[string]string | Custom tags for filtering/organization | + +## Advanced Usage + +### Health Checks + +```go +// Manual health check +if err := mgr.HealthCheck(ctx); err != nil { + log.Printf("Health check failed: %v", err) +} + +// Per-connection health check +primary, _ := mgr.Get("primary") +if err := primary.HealthCheck(ctx); err != nil { + log.Printf("Primary connection unhealthy: %v", err) + + // Manual reconnect + if err := primary.Reconnect(ctx); err != nil { + log.Printf("Reconnection failed: %v", err) + } +} +``` + +### Connection Statistics + +```go +// Get overall statistics +stats := mgr.Stats() +fmt.Printf("Total connections: %d\n", stats.TotalConnections) +fmt.Printf("Healthy: %d, Unhealthy: %d\n", stats.HealthyCount, stats.UnhealthyCount) + +// Per-connection stats +for name, connStats := range stats.ConnectionStats { + fmt.Printf("%s: %d open, %d in use, %d idle\n", + name, + connStats.OpenConnections, + connStats.InUse, + connStats.Idle) +} + +// Individual connection stats +primary, _ := mgr.Get("primary") +stats := primary.Stats() +fmt.Printf("Wait count: %d, Wait duration: %v\n", + stats.WaitCount, + stats.WaitDuration) +``` + +### Prometheus Metrics + +The package automatically exports Prometheus metrics: + +- `dbmanager_connections_total` - Total configured connections by type +- `dbmanager_connection_status` - Connection health status (1=healthy, 0=unhealthy) +- `dbmanager_connection_pool_size` - Connection pool statistics by state +- `dbmanager_connection_wait_count` - Times connections waited for availability +- `dbmanager_connection_wait_duration_seconds` - Total wait duration +- `dbmanager_health_check_duration_seconds` - Health check execution time +- `dbmanager_reconnect_attempts_total` - Reconnection attempts and results +- `dbmanager_connection_lifetime_closed_total` - Connections closed due to max lifetime +- `dbmanager_connection_idle_closed_total` - Connections closed due to max idle time + +Metrics are automatically updated during health checks. To manually publish metrics: + +```go +if mgr, ok := mgr.(*connectionManager); ok { + mgr.PublishMetrics() +} +``` + +## Architecture + +### Single Connection Pool, Multiple ORMs + +A key design principle is that Bun, GORM, and Native all wrap the **same underlying `*sql.DB`** connection pool: + +``` +┌─────────────────────────────────────┐ +│ SQL Connection │ +├─────────────────────────────────────┤ +│ ┌─────────┐ ┌──────┐ ┌────────┐ │ +│ │ Bun │ │ GORM │ │ Native │ │ +│ └────┬────┘ └───┬──┘ └───┬────┘ │ +│ │ │ │ │ +│ └───────────┴─────────┘ │ +│ *sql.DB │ +│ (single pool) │ +└─────────────────────────────────────┘ +``` + +**Benefits:** +- No connection duplication +- Consistent pool limits across all ORMs +- Unified connection statistics +- Lower resource usage + +### Provider Pattern + +Each database type has a dedicated provider: + +- **PostgresProvider** - Uses `pgx` driver +- **SQLiteProvider** - Uses `glebarez/sqlite` (pure Go) +- **MSSQLProvider** - Uses `go-mssqldb` +- **MongoProvider** - Uses official `mongo-driver` + +Providers handle: +- Connection establishment with retry logic +- Health checking +- Connection statistics +- Connection cleanup + +## Best Practices + +1. **Use Named Connections**: Be explicit about which database you're accessing + ```go + primary, _ := mgr.Get("primary") // Good + db, _ := mgr.GetDefaultDatabase() // Risky if default changes + ``` + +2. **Configure Connection Pools**: Tune based on your workload + ```yaml + connections: + primary: + max_open_conns: 100 # High traffic API + max_idle_conns: 25 + analytics: + max_open_conns: 10 # Background analytics + max_idle_conns: 2 + ``` + +3. **Enable Health Checks**: Catch connection issues early + ```yaml + health_check_interval: 30s + enable_auto_reconnect: true + ``` + +4. **Use Appropriate ORM**: Choose based on your needs + - **Bun**: Modern, fast, type-safe - recommended for new code + - **GORM**: Mature, feature-rich - good for existing GORM code + - **Native**: Maximum control - use for performance-critical queries + +5. **Monitor Metrics**: Watch connection pool utilization + - If `wait_count` is high, increase `max_open_conns` + - If `idle` is always high, decrease `max_idle_conns` + +## Troubleshooting + +### Connection Failures + +If connections fail to establish: + +1. Check configuration: + ```bash + # Test connection manually + psql -h localhost -U myuser -d myapp + ``` + +2. Enable logging: + ```yaml + connections: + primary: + enable_logging: true + ``` + +3. Check retry attempts: + ```yaml + retry_attempts: 5 # Increase retries + retry_max_delay: 30s + ``` + +### Pool Exhaustion + +If you see "too many connections" errors: + +1. Increase pool size: + ```yaml + max_open_conns: 50 # Increase from default 25 + ``` + +2. Reduce connection lifetime: + ```yaml + conn_max_lifetime: 15m # Recycle faster + ``` + +3. Monitor wait stats: + ```go + stats := primary.Stats() + if stats.WaitCount > 1000 { + log.Warn("High connection wait count") + } + ``` + +### MongoDB vs SQL Confusion + +MongoDB connections don't support SQL ORMs: + +```go +docs, _ := mgr.Get("documents") + +// ✓ Correct +mongoClient, _ := docs.MongoDB() + +// ✗ Error: ErrNotSQLDatabase +bunDB, err := docs.Bun() // Won't work! +``` + +SQL connections don't support MongoDB: + +```go +primary, _ := mgr.Get("primary") + +// ✓ Correct +bunDB, _ := primary.Bun() + +// ✗ Error: ErrNotMongoDB +mongoClient, err := primary.MongoDB() // Won't work! +``` + +## Migration Guide + +### From Raw `database/sql` + +Before: +```go +db, err := sql.Open("postgres", dsn) +defer db.Close() + +rows, err := db.Query("SELECT * FROM users") +``` + +After: +```go +mgr, _ := dbmanager.NewManager(cfg.DBManager) +mgr.Connect(ctx) +defer mgr.Close() + +primary, _ := mgr.Get("primary") +nativeDB, _ := primary.Native() + +rows, err := nativeDB.Query("SELECT * FROM users") +``` + +### From Direct Bun/GORM + +Before: +```go +sqldb, _ := sql.Open("pgx", dsn) +bunDB := bun.NewDB(sqldb, pgdialect.New()) + +var users []User +bunDB.NewSelect().Model(&users).Scan(ctx) +``` + +After: +```go +mgr, _ := dbmanager.NewManager(cfg.DBManager) +mgr.Connect(ctx) + +primary, _ := mgr.Get("primary") +bunDB, _ := primary.Bun() + +var users []User +bunDB.NewSelect().Model(&users).Scan(ctx) +``` + +## License + +Same as the parent project. + +## Contributing + +Contributions are welcome! Please submit issues and pull requests to the main repository. diff --git a/pkg/dbmanager/config.go b/pkg/dbmanager/config.go new file mode 100644 index 0000000..8f81259 --- /dev/null +++ b/pkg/dbmanager/config.go @@ -0,0 +1,448 @@ +package dbmanager + +import ( + "fmt" + "time" + + "github.com/bitechdev/ResolveSpec/pkg/config" +) + +// DatabaseType represents the type of database +type DatabaseType string + +const ( + // DatabaseTypePostgreSQL represents PostgreSQL database + DatabaseTypePostgreSQL DatabaseType = "postgres" + + // DatabaseTypeSQLite represents SQLite database + DatabaseTypeSQLite DatabaseType = "sqlite" + + // DatabaseTypeMSSQL represents Microsoft SQL Server database + DatabaseTypeMSSQL DatabaseType = "mssql" + + // DatabaseTypeMongoDB represents MongoDB database + DatabaseTypeMongoDB DatabaseType = "mongodb" +) + +// ORMType represents the ORM to use for database operations +type ORMType string + +const ( + // ORMTypeBun represents Bun ORM + ORMTypeBun ORMType = "bun" + + // ORMTypeGORM represents GORM + ORMTypeGORM ORMType = "gorm" + + // ORMTypeNative represents native database/sql + ORMTypeNative ORMType = "native" +) + +// ManagerConfig contains configuration for the database connection manager +type ManagerConfig struct { + // DefaultConnection is the name of the default connection to use + DefaultConnection string `mapstructure:"default_connection"` + + // Connections is a map of connection name to connection configuration + Connections map[string]ConnectionConfig `mapstructure:"connections"` + + // Global connection pool defaults + MaxOpenConns int `mapstructure:"max_open_conns"` + MaxIdleConns int `mapstructure:"max_idle_conns"` + ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` + + // Retry policy + RetryAttempts int `mapstructure:"retry_attempts"` + RetryDelay time.Duration `mapstructure:"retry_delay"` + RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"` + + // Health checks + HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` + EnableAutoReconnect bool `mapstructure:"enable_auto_reconnect"` +} + +// ConnectionConfig defines configuration for a single database connection +type ConnectionConfig struct { + // Name is the unique name of this connection + Name string `mapstructure:"name"` + + // Type is the database type (postgres, sqlite, mssql, mongodb) + Type DatabaseType `mapstructure:"type"` + + // DSN is the complete Data Source Name / connection string + // If provided, this takes precedence over individual connection parameters + DSN string `mapstructure:"dsn"` + + // Connection parameters (used if DSN is not provided) + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + + // PostgreSQL/MSSQL specific + SSLMode string `mapstructure:"sslmode"` // disable, require, verify-ca, verify-full + Schema string `mapstructure:"schema"` // Default schema + + // SQLite specific + FilePath string `mapstructure:"filepath"` + + // MongoDB specific + AuthSource string `mapstructure:"auth_source"` + ReplicaSet string `mapstructure:"replica_set"` + ReadPreference string `mapstructure:"read_preference"` // primary, secondary, etc. + + // Connection pool settings (overrides global defaults) + MaxOpenConns *int `mapstructure:"max_open_conns"` + MaxIdleConns *int `mapstructure:"max_idle_conns"` + ConnMaxLifetime *time.Duration `mapstructure:"conn_max_lifetime"` + ConnMaxIdleTime *time.Duration `mapstructure:"conn_max_idle_time"` + + // Timeouts + ConnectTimeout time.Duration `mapstructure:"connect_timeout"` + QueryTimeout time.Duration `mapstructure:"query_timeout"` + + // Features + EnableTracing bool `mapstructure:"enable_tracing"` + EnableMetrics bool `mapstructure:"enable_metrics"` + EnableLogging bool `mapstructure:"enable_logging"` + + // DefaultORM specifies which ORM to use for the Database() method + // Options: "bun", "gorm", "native" + DefaultORM string `mapstructure:"default_orm"` + + // Tags for organization and filtering + Tags map[string]string `mapstructure:"tags"` +} + +// DefaultManagerConfig returns a ManagerConfig with sensible defaults +func DefaultManagerConfig() ManagerConfig { + return ManagerConfig{ + DefaultConnection: "", + Connections: make(map[string]ConnectionConfig), + MaxOpenConns: 25, + MaxIdleConns: 5, + ConnMaxLifetime: 30 * time.Minute, + ConnMaxIdleTime: 5 * time.Minute, + RetryAttempts: 3, + RetryDelay: 1 * time.Second, + RetryMaxDelay: 10 * time.Second, + HealthCheckInterval: 30 * time.Second, + EnableAutoReconnect: true, + } +} + +// ApplyDefaults applies default values to the manager configuration +func (c *ManagerConfig) ApplyDefaults() { + defaults := DefaultManagerConfig() + + if c.MaxOpenConns == 0 { + c.MaxOpenConns = defaults.MaxOpenConns + } + if c.MaxIdleConns == 0 { + c.MaxIdleConns = defaults.MaxIdleConns + } + if c.ConnMaxLifetime == 0 { + c.ConnMaxLifetime = defaults.ConnMaxLifetime + } + if c.ConnMaxIdleTime == 0 { + c.ConnMaxIdleTime = defaults.ConnMaxIdleTime + } + if c.RetryAttempts == 0 { + c.RetryAttempts = defaults.RetryAttempts + } + if c.RetryDelay == 0 { + c.RetryDelay = defaults.RetryDelay + } + if c.RetryMaxDelay == 0 { + c.RetryMaxDelay = defaults.RetryMaxDelay + } + if c.HealthCheckInterval == 0 { + c.HealthCheckInterval = defaults.HealthCheckInterval + } +} + +// Validate validates the manager configuration +func (c *ManagerConfig) Validate() error { + if len(c.Connections) == 0 { + return NewConfigurationError("connections", fmt.Errorf("at least one connection must be configured")) + } + + if c.DefaultConnection != "" { + if _, ok := c.Connections[c.DefaultConnection]; !ok { + return NewConfigurationError("default_connection", fmt.Errorf("default connection '%s' not found in connections", c.DefaultConnection)) + } + } + + // Validate each connection + for name := range c.Connections { + conn := c.Connections[name] + if err := conn.Validate(); err != nil { + return fmt.Errorf("connection '%s': %w", name, err) + } + } + + return nil +} + +// ApplyDefaults applies default values and global settings to the connection configuration +func (cc *ConnectionConfig) ApplyDefaults(global *ManagerConfig) { + // Set name if not already set + if cc.Name == "" { + cc.Name = "unnamed" + } + + // Apply global pool settings if not overridden + if cc.MaxOpenConns == nil && global != nil { + maxOpen := global.MaxOpenConns + cc.MaxOpenConns = &maxOpen + } + if cc.MaxIdleConns == nil && global != nil { + maxIdle := global.MaxIdleConns + cc.MaxIdleConns = &maxIdle + } + if cc.ConnMaxLifetime == nil && global != nil { + lifetime := global.ConnMaxLifetime + cc.ConnMaxLifetime = &lifetime + } + if cc.ConnMaxIdleTime == nil && global != nil { + idleTime := global.ConnMaxIdleTime + cc.ConnMaxIdleTime = &idleTime + } + + // Default timeouts + if cc.ConnectTimeout == 0 { + cc.ConnectTimeout = 10 * time.Second + } + if cc.QueryTimeout == 0 { + cc.QueryTimeout = 30 * time.Second + } + + // Default ORM + if cc.DefaultORM == "" { + cc.DefaultORM = string(ORMTypeBun) + } + + // Default PostgreSQL port + if cc.Type == DatabaseTypePostgreSQL && cc.Port == 0 && cc.DSN == "" { + cc.Port = 5432 + } + + // Default MSSQL port + if cc.Type == DatabaseTypeMSSQL && cc.Port == 0 && cc.DSN == "" { + cc.Port = 1433 + } + + // Default MongoDB port + if cc.Type == DatabaseTypeMongoDB && cc.Port == 0 && cc.DSN == "" { + cc.Port = 27017 + } + + // Default MongoDB auth source + if cc.Type == DatabaseTypeMongoDB && cc.AuthSource == "" { + cc.AuthSource = "admin" + } +} + +// Validate validates the connection configuration +func (cc *ConnectionConfig) Validate() error { + // Validate database type + switch cc.Type { + case DatabaseTypePostgreSQL, DatabaseTypeSQLite, DatabaseTypeMSSQL, DatabaseTypeMongoDB: + // Valid types + default: + return NewConfigurationError("type", fmt.Errorf("unsupported database type: %s", cc.Type)) + } + + // Validate that either DSN or connection parameters are provided + if cc.DSN == "" { + switch cc.Type { + case DatabaseTypePostgreSQL, DatabaseTypeMSSQL, DatabaseTypeMongoDB: + if cc.Host == "" { + return NewConfigurationError("host", fmt.Errorf("host is required when DSN is not provided")) + } + if cc.Database == "" { + return NewConfigurationError("database", fmt.Errorf("database is required when DSN is not provided")) + } + case DatabaseTypeSQLite: + if cc.FilePath == "" { + return NewConfigurationError("filepath", fmt.Errorf("filepath is required for SQLite when DSN is not provided")) + } + } + } + + // Validate ORM type + if cc.DefaultORM != "" { + switch ORMType(cc.DefaultORM) { + case ORMTypeBun, ORMTypeGORM, ORMTypeNative: + // Valid ORM types + default: + return NewConfigurationError("default_orm", fmt.Errorf("unsupported ORM type: %s", cc.DefaultORM)) + } + } + + return nil +} + +// BuildDSN builds a connection string from individual parameters +func (cc *ConnectionConfig) BuildDSN() (string, error) { + // If DSN is already provided, use it + if cc.DSN != "" { + return cc.DSN, nil + } + + switch cc.Type { + case DatabaseTypePostgreSQL: + return cc.buildPostgresDSN(), nil + case DatabaseTypeSQLite: + return cc.buildSQLiteDSN(), nil + case DatabaseTypeMSSQL: + return cc.buildMSSQLDSN(), nil + case DatabaseTypeMongoDB: + return cc.buildMongoDSN(), nil + default: + return "", fmt.Errorf("cannot build DSN for database type: %s", cc.Type) + } +} + +func (cc *ConnectionConfig) buildPostgresDSN() string { + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s", + cc.Host, cc.Port, cc.User, cc.Password, cc.Database) + + if cc.SSLMode != "" { + dsn += fmt.Sprintf(" sslmode=%s", cc.SSLMode) + } else { + dsn += " sslmode=disable" + } + + if cc.Schema != "" { + dsn += fmt.Sprintf(" search_path=%s", cc.Schema) + } + + return dsn +} + +func (cc *ConnectionConfig) buildSQLiteDSN() string { + if cc.FilePath != "" { + return cc.FilePath + } + return ":memory:" +} + +func (cc *ConnectionConfig) buildMSSQLDSN() string { + // Format: sqlserver://username:password@host:port?database=dbname + dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", + cc.User, cc.Password, cc.Host, cc.Port, cc.Database) + + if cc.Schema != "" { + dsn += fmt.Sprintf("&schema=%s", cc.Schema) + } + + return dsn +} + +func (cc *ConnectionConfig) buildMongoDSN() string { + // Format: mongodb://username:password@host:port/database?authSource=admin + var dsn string + + if cc.User != "" && cc.Password != "" { + dsn = fmt.Sprintf("mongodb://%s:%s@%s:%d/%s", + cc.User, cc.Password, cc.Host, cc.Port, cc.Database) + } else { + dsn = fmt.Sprintf("mongodb://%s:%d/%s", cc.Host, cc.Port, cc.Database) + } + + params := "" + if cc.AuthSource != "" { + params += fmt.Sprintf("authSource=%s", cc.AuthSource) + } + if cc.ReplicaSet != "" { + if params != "" { + params += "&" + } + params += fmt.Sprintf("replicaSet=%s", cc.ReplicaSet) + } + if cc.ReadPreference != "" { + if params != "" { + params += "&" + } + params += fmt.Sprintf("readPreference=%s", cc.ReadPreference) + } + + if params != "" { + dsn += "?" + params + } + + return dsn +} + +// FromConfig converts config.DBManagerConfig to internal ManagerConfig +func FromConfig(cfg config.DBManagerConfig) ManagerConfig { + mgr := ManagerConfig{ + DefaultConnection: cfg.DefaultConnection, + Connections: make(map[string]ConnectionConfig), + MaxOpenConns: cfg.MaxOpenConns, + MaxIdleConns: cfg.MaxIdleConns, + ConnMaxLifetime: cfg.ConnMaxLifetime, + ConnMaxIdleTime: cfg.ConnMaxIdleTime, + RetryAttempts: cfg.RetryAttempts, + RetryDelay: cfg.RetryDelay, + RetryMaxDelay: cfg.RetryMaxDelay, + HealthCheckInterval: cfg.HealthCheckInterval, + EnableAutoReconnect: cfg.EnableAutoReconnect, + } + + // Convert connections + for name := range cfg.Connections { + connCfg := cfg.Connections[name] + mgr.Connections[name] = ConnectionConfig{ + Name: connCfg.Name, + Type: DatabaseType(connCfg.Type), + DSN: connCfg.DSN, + Host: connCfg.Host, + Port: connCfg.Port, + User: connCfg.User, + Password: connCfg.Password, + Database: connCfg.Database, + SSLMode: connCfg.SSLMode, + Schema: connCfg.Schema, + FilePath: connCfg.FilePath, + AuthSource: connCfg.AuthSource, + ReplicaSet: connCfg.ReplicaSet, + ReadPreference: connCfg.ReadPreference, + MaxOpenConns: connCfg.MaxOpenConns, + MaxIdleConns: connCfg.MaxIdleConns, + ConnMaxLifetime: connCfg.ConnMaxLifetime, + ConnMaxIdleTime: connCfg.ConnMaxIdleTime, + ConnectTimeout: connCfg.ConnectTimeout, + QueryTimeout: connCfg.QueryTimeout, + EnableTracing: connCfg.EnableTracing, + EnableMetrics: connCfg.EnableMetrics, + EnableLogging: connCfg.EnableLogging, + DefaultORM: connCfg.DefaultORM, + Tags: connCfg.Tags, + } + } + + return mgr +} + +// Getter methods to implement providers.ConnectionConfig interface +func (cc *ConnectionConfig) GetName() string { return cc.Name } +func (cc *ConnectionConfig) GetType() string { return string(cc.Type) } +func (cc *ConnectionConfig) GetHost() string { return cc.Host } +func (cc *ConnectionConfig) GetPort() int { return cc.Port } +func (cc *ConnectionConfig) GetUser() string { return cc.User } +func (cc *ConnectionConfig) GetPassword() string { return cc.Password } +func (cc *ConnectionConfig) GetDatabase() string { return cc.Database } +func (cc *ConnectionConfig) GetFilePath() string { return cc.FilePath } +func (cc *ConnectionConfig) GetConnectTimeout() time.Duration { return cc.ConnectTimeout } +func (cc *ConnectionConfig) GetEnableLogging() bool { return cc.EnableLogging } +func (cc *ConnectionConfig) GetMaxOpenConns() *int { return cc.MaxOpenConns } +func (cc *ConnectionConfig) GetMaxIdleConns() *int { return cc.MaxIdleConns } +func (cc *ConnectionConfig) GetConnMaxLifetime() *time.Duration { return cc.ConnMaxLifetime } +func (cc *ConnectionConfig) GetConnMaxIdleTime() *time.Duration { return cc.ConnMaxIdleTime } +func (cc *ConnectionConfig) GetQueryTimeout() time.Duration { return cc.QueryTimeout } +func (cc *ConnectionConfig) GetEnableMetrics() bool { return cc.EnableMetrics } +func (cc *ConnectionConfig) GetReadPreference() string { return cc.ReadPreference } diff --git a/pkg/dbmanager/connection.go b/pkg/dbmanager/connection.go new file mode 100644 index 0000000..715c1fe --- /dev/null +++ b/pkg/dbmanager/connection.go @@ -0,0 +1,607 @@ +package dbmanager + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" + "go.mongodb.org/mongo-driver/mongo" + "gorm.io/gorm" + + "github.com/bitechdev/ResolveSpec/pkg/common" + "github.com/bitechdev/ResolveSpec/pkg/common/adapters/database" +) + +// Connection represents a single named database connection +type Connection interface { + // Metadata + Name() string + Type() DatabaseType + + // ORM Access (SQL databases only) + Bun() (*bun.DB, error) + GORM() (*gorm.DB, error) + Native() (*sql.DB, error) + + // Common Database interface (for SQL databases) + Database() (common.Database, error) + + // MongoDB Access (MongoDB only) + MongoDB() (*mongo.Client, error) + + // Lifecycle + Connect(ctx context.Context) error + Close() error + HealthCheck(ctx context.Context) error + Reconnect(ctx context.Context) error + + // Stats + Stats() *ConnectionStats +} + +// ConnectionStats contains statistics about a database connection +type ConnectionStats struct { + Name string + Type DatabaseType + Connected bool + LastHealthCheck time.Time + HealthCheckStatus string + + // SQL connection pool stats + OpenConnections int + InUse int + Idle int + WaitCount int64 + WaitDuration time.Duration + MaxIdleClosed int64 + MaxLifetimeClosed int64 +} + +// sqlConnection implements Connection for SQL databases (PostgreSQL, SQLite, MSSQL) +type sqlConnection struct { + name string + dbType DatabaseType + config ConnectionConfig + provider Provider + + // Lazy-initialized ORM instances (all wrap the same sql.DB) + nativeDB *sql.DB + bunDB *bun.DB + gormDB *gorm.DB + + // Adapters for common.Database interface + bunAdapter *database.BunAdapter + gormAdapter *database.GormAdapter + nativeAdapter common.Database + + // State + connected bool + mu sync.RWMutex + + // Health check + lastHealthCheck time.Time + healthCheckStatus string +} + +// newSQLConnection creates a new SQL connection +func newSQLConnection(name string, dbType DatabaseType, config ConnectionConfig, provider Provider) *sqlConnection { + return &sqlConnection{ + name: name, + dbType: dbType, + config: config, + provider: provider, + } +} + +// Name returns the connection name +func (c *sqlConnection) Name() string { + return c.name +} + +// Type returns the database type +func (c *sqlConnection) Type() DatabaseType { + return c.dbType +} + +// Connect establishes the database connection +func (c *sqlConnection) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + return ErrAlreadyConnected + } + + if err := c.provider.Connect(ctx, &c.config); err != nil { + return NewConnectionError(c.name, "connect", err) + } + + c.connected = true + return nil +} + +// Close closes the database connection and all ORM instances +func (c *sqlConnection) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.connected { + return nil + } + + // Close Bun if initialized + if c.bunDB != nil { + if err := c.bunDB.Close(); err != nil { + return NewConnectionError(c.name, "close bun", err) + } + } + + // GORM doesn't have a separate close - it uses the underlying sql.DB + + // Close the provider (which closes the underlying sql.DB) + if err := c.provider.Close(); err != nil { + return NewConnectionError(c.name, "close", err) + } + + c.connected = false + c.nativeDB = nil + c.bunDB = nil + c.gormDB = nil + c.bunAdapter = nil + c.gormAdapter = nil + c.nativeAdapter = nil + + return nil +} + +// HealthCheck verifies the connection is alive +func (c *sqlConnection) HealthCheck(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.lastHealthCheck = time.Now() + + if !c.connected { + c.healthCheckStatus = "disconnected" + return ErrConnectionClosed + } + + if err := c.provider.HealthCheck(ctx); err != nil { + c.healthCheckStatus = "unhealthy: " + err.Error() + return NewConnectionError(c.name, "health check", err) + } + + c.healthCheckStatus = "healthy" + return nil +} + +// Reconnect closes and re-establishes the connection +func (c *sqlConnection) Reconnect(ctx context.Context) error { + if err := c.Close(); err != nil { + return err + } + return c.Connect(ctx) +} + +// Native returns the native *sql.DB connection +func (c *sqlConnection) Native() (*sql.DB, error) { + c.mu.RLock() + if c.nativeDB != nil { + defer c.mu.RUnlock() + return c.nativeDB, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if c.nativeDB != nil { + return c.nativeDB, nil + } + + if !c.connected { + return nil, ErrConnectionClosed + } + + // Get native connection from provider + db, err := c.provider.GetNative() + if err != nil { + return nil, NewConnectionError(c.name, "get native", err) + } + + c.nativeDB = db + return c.nativeDB, nil +} + +// Bun returns a Bun ORM instance wrapping the native connection +func (c *sqlConnection) Bun() (*bun.DB, error) { + c.mu.RLock() + if c.bunDB != nil { + defer c.mu.RUnlock() + return c.bunDB, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if c.bunDB != nil { + return c.bunDB, nil + } + + // Get native connection first + native, err := c.provider.GetNative() + if err != nil { + return nil, NewConnectionError(c.name, "get bun", err) + } + + // Create Bun DB wrapping the same sql.DB + dialect := c.getBunDialect() + c.bunDB = bun.NewDB(native, dialect) + + return c.bunDB, nil +} + +// GORM returns a GORM instance wrapping the native connection +func (c *sqlConnection) GORM() (*gorm.DB, error) { + c.mu.RLock() + if c.gormDB != nil { + defer c.mu.RUnlock() + return c.gormDB, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + // Double-check after acquiring write lock + if c.gormDB != nil { + return c.gormDB, nil + } + + // Get native connection first + native, err := c.provider.GetNative() + if err != nil { + return nil, NewConnectionError(c.name, "get gorm", err) + } + + // Create GORM DB wrapping the same sql.DB + dialector := c.getGORMDialector(native) + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + return nil, NewConnectionError(c.name, "initialize gorm", err) + } + + c.gormDB = db + return c.gormDB, nil +} + +// Database returns the common.Database interface using the configured default ORM +func (c *sqlConnection) Database() (common.Database, error) { + c.mu.RLock() + defaultORM := c.config.DefaultORM + c.mu.RUnlock() + + switch ORMType(defaultORM) { + case ORMTypeBun: + return c.getBunAdapter() + case ORMTypeGORM: + return c.getGORMAdapter() + case ORMTypeNative: + return c.getNativeAdapter() + default: + // Default to Bun + return c.getBunAdapter() + } +} + +// MongoDB returns an error for SQL connections +func (c *sqlConnection) MongoDB() (*mongo.Client, error) { + return nil, ErrNotMongoDB +} + +// Stats returns connection statistics +func (c *sqlConnection) Stats() *ConnectionStats { + c.mu.RLock() + defer c.mu.RUnlock() + + stats := &ConnectionStats{ + Name: c.name, + Type: c.dbType, + Connected: c.connected, + LastHealthCheck: c.lastHealthCheck, + HealthCheckStatus: c.healthCheckStatus, + } + + // Get SQL stats if connected + if c.connected && c.provider != nil { + if providerStats := c.provider.Stats(); providerStats != nil { + stats.OpenConnections = providerStats.OpenConnections + stats.InUse = providerStats.InUse + stats.Idle = providerStats.Idle + stats.WaitCount = providerStats.WaitCount + stats.WaitDuration = providerStats.WaitDuration + stats.MaxIdleClosed = providerStats.MaxIdleClosed + stats.MaxLifetimeClosed = providerStats.MaxLifetimeClosed + } + } + + return stats +} + +// getBunAdapter returns or creates the Bun adapter +func (c *sqlConnection) getBunAdapter() (common.Database, error) { + c.mu.RLock() + if c.bunAdapter != nil { + defer c.mu.RUnlock() + return c.bunAdapter, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.bunAdapter != nil { + return c.bunAdapter, nil + } + + bunDB, err := c.Bun() + if err != nil { + return nil, err + } + + c.bunAdapter = database.NewBunAdapter(bunDB) + return c.bunAdapter, nil +} + +// getGORMAdapter returns or creates the GORM adapter +func (c *sqlConnection) getGORMAdapter() (common.Database, error) { + c.mu.RLock() + if c.gormAdapter != nil { + defer c.mu.RUnlock() + return c.gormAdapter, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.gormAdapter != nil { + return c.gormAdapter, nil + } + + gormDB, err := c.GORM() + if err != nil { + return nil, err + } + + c.gormAdapter = database.NewGormAdapter(gormDB) + return c.gormAdapter, nil +} + +// getNativeAdapter returns or creates the native adapter +func (c *sqlConnection) getNativeAdapter() (common.Database, error) { + c.mu.RLock() + if c.nativeAdapter != nil { + defer c.mu.RUnlock() + return c.nativeAdapter, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.nativeAdapter != nil { + return c.nativeAdapter, nil + } + + native, err := c.Native() + if err != nil { + return nil, err + } + + // Create a native adapter based on database type + switch c.dbType { + case DatabaseTypePostgreSQL: + c.nativeAdapter = database.NewPgSQLAdapter(native) + case DatabaseTypeSQLite: + // For SQLite, we'll use the PgSQL adapter as it works with standard sql.DB + c.nativeAdapter = database.NewPgSQLAdapter(native) + case DatabaseTypeMSSQL: + // For MSSQL, we'll use the PgSQL adapter as it works with standard sql.DB + c.nativeAdapter = database.NewPgSQLAdapter(native) + default: + return nil, ErrUnsupportedDatabase + } + + return c.nativeAdapter, nil +} + +// getBunDialect returns the appropriate Bun dialect for the database type +func (c *sqlConnection) getBunDialect() schema.Dialect { + switch c.dbType { + case DatabaseTypePostgreSQL: + return database.GetPostgresDialect() + case DatabaseTypeSQLite: + return database.GetSQLiteDialect() + case DatabaseTypeMSSQL: + return database.GetMSSQLDialect() + default: + // Default to PostgreSQL + return database.GetPostgresDialect() + } +} + +// getGORMDialector returns the appropriate GORM dialector for the database type +func (c *sqlConnection) getGORMDialector(db *sql.DB) gorm.Dialector { + switch c.dbType { + case DatabaseTypePostgreSQL: + return database.GetPostgresDialector(db) + case DatabaseTypeSQLite: + return database.GetSQLiteDialector(db) + case DatabaseTypeMSSQL: + return database.GetMSSQLDialector(db) + default: + // Default to PostgreSQL + return database.GetPostgresDialector(db) + } +} + +// mongoConnection implements Connection for MongoDB +type mongoConnection struct { + name string + config ConnectionConfig + provider Provider + + // MongoDB client + client *mongo.Client + + // State + connected bool + mu sync.RWMutex + + // Health check + lastHealthCheck time.Time + healthCheckStatus string +} + +// newMongoConnection creates a new MongoDB connection +func newMongoConnection(name string, config ConnectionConfig, provider Provider) *mongoConnection { + return &mongoConnection{ + name: name, + config: config, + provider: provider, + } +} + +// Name returns the connection name +func (c *mongoConnection) Name() string { + return c.name +} + +// Type returns the database type (MongoDB) +func (c *mongoConnection) Type() DatabaseType { + return DatabaseTypeMongoDB +} + +// Connect establishes the MongoDB connection +func (c *mongoConnection) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.connected { + return ErrAlreadyConnected + } + + if err := c.provider.Connect(ctx, &c.config); err != nil { + return NewConnectionError(c.name, "connect", err) + } + + // Get the mongo client + client, err := c.provider.GetMongo() + if err != nil { + return NewConnectionError(c.name, "get mongo client", err) + } + + c.client = client + c.connected = true + return nil +} + +// Close closes the MongoDB connection +func (c *mongoConnection) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.connected { + return nil + } + + if err := c.provider.Close(); err != nil { + return NewConnectionError(c.name, "close", err) + } + + c.connected = false + c.client = nil + return nil +} + +// HealthCheck verifies the MongoDB connection is alive +func (c *mongoConnection) HealthCheck(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.lastHealthCheck = time.Now() + + if !c.connected { + c.healthCheckStatus = "disconnected" + return ErrConnectionClosed + } + + if err := c.provider.HealthCheck(ctx); err != nil { + c.healthCheckStatus = "unhealthy: " + err.Error() + return NewConnectionError(c.name, "health check", err) + } + + c.healthCheckStatus = "healthy" + return nil +} + +// Reconnect closes and re-establishes the MongoDB connection +func (c *mongoConnection) Reconnect(ctx context.Context) error { + if err := c.Close(); err != nil { + return err + } + return c.Connect(ctx) +} + +// MongoDB returns the MongoDB client +func (c *mongoConnection) MongoDB() (*mongo.Client, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + if !c.connected || c.client == nil { + return nil, ErrConnectionClosed + } + + return c.client, nil +} + +// Bun returns an error for MongoDB connections +func (c *mongoConnection) Bun() (*bun.DB, error) { + return nil, ErrNotSQLDatabase +} + +// GORM returns an error for MongoDB connections +func (c *mongoConnection) GORM() (*gorm.DB, error) { + return nil, ErrNotSQLDatabase +} + +// Native returns an error for MongoDB connections +func (c *mongoConnection) Native() (*sql.DB, error) { + return nil, ErrNotSQLDatabase +} + +// Database returns an error for MongoDB connections +func (c *mongoConnection) Database() (common.Database, error) { + return nil, ErrNotSQLDatabase +} + +// Stats returns connection statistics for MongoDB +func (c *mongoConnection) Stats() *ConnectionStats { + c.mu.RLock() + defer c.mu.RUnlock() + + return &ConnectionStats{ + Name: c.name, + Type: DatabaseTypeMongoDB, + Connected: c.connected, + LastHealthCheck: c.lastHealthCheck, + HealthCheckStatus: c.healthCheckStatus, + } +} diff --git a/pkg/dbmanager/errors.go b/pkg/dbmanager/errors.go new file mode 100644 index 0000000..5859731 --- /dev/null +++ b/pkg/dbmanager/errors.go @@ -0,0 +1,82 @@ +package dbmanager + +import ( + "errors" + "fmt" +) + +// Common errors +var ( + // ErrConnectionNotFound is returned when a connection with the given name doesn't exist + ErrConnectionNotFound = errors.New("connection not found") + + // ErrInvalidConfiguration is returned when the configuration is invalid + ErrInvalidConfiguration = errors.New("invalid configuration") + + // ErrConnectionClosed is returned when attempting to use a closed connection + ErrConnectionClosed = errors.New("connection is closed") + + // ErrNotSQLDatabase is returned when attempting SQL operations on a non-SQL database + ErrNotSQLDatabase = errors.New("not a SQL database") + + // ErrNotMongoDB is returned when attempting MongoDB operations on a non-MongoDB connection + ErrNotMongoDB = errors.New("not a MongoDB connection") + + // ErrUnsupportedDatabase is returned when the database type is not supported + ErrUnsupportedDatabase = errors.New("unsupported database type") + + // ErrNoDefaultConnection is returned when no default connection is configured + ErrNoDefaultConnection = errors.New("no default connection configured") + + // ErrAlreadyConnected is returned when attempting to connect an already connected connection + ErrAlreadyConnected = errors.New("already connected") +) + +// ConnectionError wraps errors that occur during connection operations +type ConnectionError struct { + Name string + Operation string + Err error +} + +func (e *ConnectionError) Error() string { + return fmt.Sprintf("connection '%s' %s: %v", e.Name, e.Operation, e.Err) +} + +func (e *ConnectionError) Unwrap() error { + return e.Err +} + +// NewConnectionError creates a new ConnectionError +func NewConnectionError(name, operation string, err error) *ConnectionError { + return &ConnectionError{ + Name: name, + Operation: operation, + Err: err, + } +} + +// ConfigurationError wraps configuration-related errors +type ConfigurationError struct { + Field string + Err error +} + +func (e *ConfigurationError) Error() string { + if e.Field != "" { + return fmt.Sprintf("configuration error in field '%s': %v", e.Field, e.Err) + } + return fmt.Sprintf("configuration error: %v", e.Err) +} + +func (e *ConfigurationError) Unwrap() error { + return e.Err +} + +// NewConfigurationError creates a new ConfigurationError +func NewConfigurationError(field string, err error) *ConfigurationError { + return &ConfigurationError{ + Field: field, + Err: err, + } +} diff --git a/pkg/dbmanager/factory.go b/pkg/dbmanager/factory.go new file mode 100644 index 0000000..9a0efa2 --- /dev/null +++ b/pkg/dbmanager/factory.go @@ -0,0 +1,51 @@ +package dbmanager + +import ( + "fmt" + + "github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers" +) + +// createConnection creates a database connection based on the configuration +func createConnection(cfg ConnectionConfig) (Connection, error) { + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid connection configuration: %w", err) + } + + // Create provider based on database type + provider, err := createProvider(cfg.Type) + if err != nil { + return nil, err + } + + // Create connection wrapper based on database type + switch cfg.Type { + case DatabaseTypePostgreSQL, DatabaseTypeSQLite, DatabaseTypeMSSQL: + return newSQLConnection(cfg.Name, cfg.Type, cfg, provider), nil + case DatabaseTypeMongoDB: + return newMongoConnection(cfg.Name, cfg, provider), nil + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedDatabase, cfg.Type) + } +} + +// createProvider creates a database provider based on the database type +func createProvider(dbType DatabaseType) (Provider, error) { + switch dbType { + case DatabaseTypePostgreSQL: + return providers.NewPostgresProvider(), nil + case DatabaseTypeSQLite: + return providers.NewSQLiteProvider(), nil + case DatabaseTypeMSSQL: + return providers.NewMSSQLProvider(), nil + case DatabaseTypeMongoDB: + return providers.NewMongoProvider(), nil + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedDatabase, dbType) + } +} + +// Provider is an alias to the providers.Provider interface +// This allows dbmanager package consumers to use Provider without importing providers +type Provider = providers.Provider diff --git a/pkg/dbmanager/manager.go b/pkg/dbmanager/manager.go new file mode 100644 index 0000000..e7c4795 --- /dev/null +++ b/pkg/dbmanager/manager.go @@ -0,0 +1,326 @@ +package dbmanager + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/bitechdev/ResolveSpec/pkg/common" + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// Manager manages multiple named database connections +type Manager interface { + // Connection retrieval + Get(name string) (Connection, error) + GetDefault() (Connection, error) + GetAll() map[string]Connection + + // Default database management + GetDefaultDatabase() (common.Database, error) + SetDefaultDatabase(name string) error + + // Lifecycle + Connect(ctx context.Context) error + Close() error + HealthCheck(ctx context.Context) error + + // Stats + Stats() *ManagerStats +} + +// ManagerStats contains statistics about the connection manager +type ManagerStats struct { + TotalConnections int + HealthyCount int + UnhealthyCount int + ConnectionStats map[string]*ConnectionStats +} + +// connectionManager implements Manager +type connectionManager struct { + connections map[string]Connection + config ManagerConfig + mu sync.RWMutex + + // Background health check + healthTicker *time.Ticker + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewManager creates a new database connection manager +func NewManager(cfg ManagerConfig) (Manager, error) { + // Apply defaults and validate configuration + cfg.ApplyDefaults() + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + mgr := &connectionManager{ + connections: make(map[string]Connection), + config: cfg, + stopChan: make(chan struct{}), + } + + return mgr, nil +} + +// Get retrieves a named connection +func (m *connectionManager) Get(name string) (Connection, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + conn, ok := m.connections[name] + if !ok { + return nil, fmt.Errorf("%w: %s", ErrConnectionNotFound, name) + } + + return conn, nil +} + +// GetDefault retrieves the default connection +func (m *connectionManager) GetDefault() (Connection, error) { + m.mu.RLock() + defaultName := m.config.DefaultConnection + m.mu.RUnlock() + + if defaultName == "" { + return nil, ErrNoDefaultConnection + } + + return m.Get(defaultName) +} + +// GetAll returns all connections +func (m *connectionManager) GetAll() map[string]Connection { + m.mu.RLock() + defer m.mu.RUnlock() + + // Create a copy to avoid concurrent access issues + result := make(map[string]Connection, len(m.connections)) + for name, conn := range m.connections { + result[name] = conn + } + + return result +} + +// GetDefaultDatabase returns the common.Database interface from the default connection +func (m *connectionManager) GetDefaultDatabase() (common.Database, error) { + conn, err := m.GetDefault() + if err != nil { + return nil, err + } + + db, err := conn.Database() + if err != nil { + return nil, fmt.Errorf("failed to get database from default connection: %w", err) + } + + return db, nil +} + +// SetDefaultDatabase sets the default database connection by name +func (m *connectionManager) SetDefaultDatabase(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Verify the connection exists + if _, ok := m.connections[name]; !ok { + return fmt.Errorf("%w: %s", ErrConnectionNotFound, name) + } + + m.config.DefaultConnection = name + logger.Info("Default database connection changed: name=%s", name) + + return nil +} + +// Connect establishes all configured database connections +func (m *connectionManager) Connect(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Create connections from configuration + for name := range m.config.Connections { + // Get a copy of the connection config + connCfg := m.config.Connections[name] + // Apply global defaults to connection config + connCfg.ApplyDefaults(&m.config) + connCfg.Name = name + + // Create connection using factory + conn, err := createConnection(connCfg) + if err != nil { + return fmt.Errorf("failed to create connection '%s': %w", name, err) + } + + // Connect + if err := conn.Connect(ctx); err != nil { + return fmt.Errorf("failed to connect '%s': %w", name, err) + } + + m.connections[name] = conn + logger.Info("Database connection established: name=%s, type=%s", name, connCfg.Type) + } + + // Start background health checks if enabled + if m.config.EnableAutoReconnect && m.config.HealthCheckInterval > 0 { + m.startHealthChecker() + } + + logger.Info("Database manager initialized: connections=%d", len(m.connections)) + return nil +} + +// Close closes all database connections +func (m *connectionManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + // Stop health checker + m.stopHealthChecker() + + // Close all connections + var errors []error + for name, conn := range m.connections { + if err := conn.Close(); err != nil { + errors = append(errors, fmt.Errorf("failed to close connection '%s': %w", name, err)) + logger.Error("Failed to close connection", "name", name, "error", err) + } else { + logger.Info("Connection closed: name=%s", name) + } + } + + m.connections = make(map[string]Connection) + + if len(errors) > 0 { + return fmt.Errorf("errors closing connections: %v", errors) + } + + logger.Info("Database manager closed") + return nil +} + +// HealthCheck performs health checks on all connections +func (m *connectionManager) HealthCheck(ctx context.Context) error { + m.mu.RLock() + connections := make(map[string]Connection, len(m.connections)) + for name, conn := range m.connections { + connections[name] = conn + } + m.mu.RUnlock() + + var errors []error + for name, conn := range connections { + if err := conn.HealthCheck(ctx); err != nil { + errors = append(errors, fmt.Errorf("connection '%s': %w", name, err)) + } + } + + if len(errors) > 0 { + return fmt.Errorf("health check failed for %d connections: %v", len(errors), errors) + } + + return nil +} + +// Stats returns statistics for all connections +func (m *connectionManager) Stats() *ManagerStats { + m.mu.RLock() + defer m.mu.RUnlock() + + stats := &ManagerStats{ + TotalConnections: len(m.connections), + ConnectionStats: make(map[string]*ConnectionStats), + } + + for name, conn := range m.connections { + connStats := conn.Stats() + stats.ConnectionStats[name] = connStats + + if connStats.Connected && connStats.HealthCheckStatus == "healthy" { + stats.HealthyCount++ + } else { + stats.UnhealthyCount++ + } + } + + return stats +} + +// startHealthChecker starts background health checking +func (m *connectionManager) startHealthChecker() { + if m.healthTicker != nil { + return // Already running + } + + m.healthTicker = time.NewTicker(m.config.HealthCheckInterval) + + m.wg.Add(1) + go func() { + defer m.wg.Done() + logger.Info("Health checker started: interval=%v", m.config.HealthCheckInterval) + + for { + select { + case <-m.healthTicker.C: + m.performHealthCheck() + case <-m.stopChan: + logger.Info("Health checker stopped") + return + } + } + }() +} + +// stopHealthChecker stops background health checking +func (m *connectionManager) stopHealthChecker() { + if m.healthTicker != nil { + m.healthTicker.Stop() + close(m.stopChan) + m.wg.Wait() + m.healthTicker = nil + } +} + +// performHealthCheck performs a health check on all connections +func (m *connectionManager) performHealthCheck() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + m.mu.RLock() + connections := make([]struct { + name string + conn Connection + }, 0, len(m.connections)) + for name, conn := range m.connections { + connections = append(connections, struct { + name string + conn Connection + }{name, conn}) + } + m.mu.RUnlock() + + for _, item := range connections { + if err := item.conn.HealthCheck(ctx); err != nil { + logger.Warn("Health check failed", + "connection", item.name, + "error", err) + + // Attempt reconnection if enabled + if m.config.EnableAutoReconnect { + logger.Info("Attempting reconnection: connection=%s", item.name) + if err := item.conn.Reconnect(ctx); err != nil { + logger.Error("Reconnection failed", + "connection", item.name, + "error", err) + } else { + logger.Info("Reconnection successful: connection=%s", item.name) + } + } + } + } +} diff --git a/pkg/dbmanager/metrics.go b/pkg/dbmanager/metrics.go new file mode 100644 index 0000000..3878891 --- /dev/null +++ b/pkg/dbmanager/metrics.go @@ -0,0 +1,136 @@ +package dbmanager + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // connectionsTotal tracks the total number of configured database connections + connectionsTotal = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connections_total", + Help: "Total number of configured database connections", + }, + []string{"type"}, + ) + + // connectionStatus tracks connection health status (1=healthy, 0=unhealthy) + connectionStatus = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_status", + Help: "Connection status (1=healthy, 0=unhealthy)", + }, + []string{"name", "type"}, + ) + + // connectionPoolSize tracks connection pool sizes + connectionPoolSize = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_pool_size", + Help: "Current connection pool size", + }, + []string{"name", "type", "state"}, // state: open, idle, in_use + ) + + // connectionWaitCount tracks how many times connections had to wait for availability + connectionWaitCount = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_wait_count", + Help: "Number of times connections had to wait for availability", + }, + []string{"name", "type"}, + ) + + // connectionWaitDuration tracks total time connections spent waiting + connectionWaitDuration = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_wait_duration_seconds", + Help: "Total time connections spent waiting for availability", + }, + []string{"name", "type"}, + ) + + // reconnectAttempts tracks reconnection attempts and their outcomes + reconnectAttempts = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "dbmanager_reconnect_attempts_total", + Help: "Total number of reconnection attempts", + }, + []string{"name", "type", "result"}, // result: success, failure + ) + + // connectionLifetimeClosed tracks connections closed due to max lifetime + connectionLifetimeClosed = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_lifetime_closed_total", + Help: "Total connections closed due to exceeding max lifetime", + }, + []string{"name", "type"}, + ) + + // connectionIdleClosed tracks connections closed due to max idle time + connectionIdleClosed = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "dbmanager_connection_idle_closed_total", + Help: "Total connections closed due to exceeding max idle time", + }, + []string{"name", "type"}, + ) +) + +// PublishMetrics publishes current metrics for all connections +func (m *connectionManager) PublishMetrics() { + stats := m.Stats() + + // Count connections by type + typeCount := make(map[DatabaseType]int) + for _, connStats := range stats.ConnectionStats { + typeCount[connStats.Type]++ + } + + // Update total connections gauge + for dbType, count := range typeCount { + connectionsTotal.WithLabelValues(string(dbType)).Set(float64(count)) + } + + // Update per-connection metrics + for name, connStats := range stats.ConnectionStats { + labels := prometheus.Labels{ + "name": name, + "type": string(connStats.Type), + } + + // Connection status + status := float64(0) + if connStats.Connected && connStats.HealthCheckStatus == "healthy" { + status = 1 + } + connectionStatus.With(labels).Set(status) + + // Pool size metrics (SQL databases only) + if connStats.Type != DatabaseTypeMongoDB { + connectionPoolSize.WithLabelValues(name, string(connStats.Type), "open").Set(float64(connStats.OpenConnections)) + connectionPoolSize.WithLabelValues(name, string(connStats.Type), "idle").Set(float64(connStats.Idle)) + connectionPoolSize.WithLabelValues(name, string(connStats.Type), "in_use").Set(float64(connStats.InUse)) + + // Wait stats + connectionWaitCount.With(labels).Set(float64(connStats.WaitCount)) + connectionWaitDuration.With(labels).Set(connStats.WaitDuration.Seconds()) + + // Lifetime/idle closed + connectionLifetimeClosed.With(labels).Set(float64(connStats.MaxLifetimeClosed)) + connectionIdleClosed.With(labels).Set(float64(connStats.MaxIdleClosed)) + } + } +} + +// RecordReconnectAttempt records a reconnection attempt +func RecordReconnectAttempt(name string, dbType DatabaseType, success bool) { + result := "failure" + if success { + result = "success" + } + + reconnectAttempts.WithLabelValues(name, string(dbType), result).Inc() +} diff --git a/pkg/dbmanager/providers/POSTGRES_NOTIFY_LISTEN.md b/pkg/dbmanager/providers/POSTGRES_NOTIFY_LISTEN.md new file mode 100644 index 0000000..46dd564 --- /dev/null +++ b/pkg/dbmanager/providers/POSTGRES_NOTIFY_LISTEN.md @@ -0,0 +1,319 @@ +# PostgreSQL NOTIFY/LISTEN Support + +The `dbmanager` package provides built-in support for PostgreSQL's NOTIFY/LISTEN functionality through the `PostgresListener` type. + +## Overview + +PostgreSQL NOTIFY/LISTEN is a simple pub/sub mechanism that allows database clients to: +- **LISTEN** on named channels to receive notifications +- **NOTIFY** channels to send messages to all listeners +- Receive asynchronous notifications without polling + +## Features + +- ✅ Subscribe to multiple channels simultaneously +- ✅ Callback-based notification handling +- ✅ Automatic reconnection on connection loss +- ✅ Automatic resubscription after reconnection +- ✅ Thread-safe operations +- ✅ Panic recovery in notification handlers +- ✅ Dedicated connection for listening (doesn't interfere with queries) + +## Usage + +### Basic Usage + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers" +) + +func main() { + // Create PostgreSQL provider + cfg := &providers.Config{ + Name: "primary", + Type: "postgres", + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Database: "myapp", + } + + provider := providers.NewPostgresProvider() + ctx := context.Background() + + if err := provider.Connect(ctx, cfg); err != nil { + panic(err) + } + defer provider.Close() + + // Get listener + listener, err := provider.GetListener(ctx) + if err != nil { + panic(err) + } + + // Subscribe to a channel + err = listener.Listen("events", func(channel, payload string) { + fmt.Printf("Received on %s: %s\n", channel, payload) + }) + if err != nil { + panic(err) + } + + // Send a notification + err = listener.Notify(ctx, "events", "Hello, World!") + if err != nil { + panic(err) + } + + // Keep the program running + time.Sleep(1 * time.Second) +} +``` + +### Multiple Channels + +```go +listener, _ := provider.GetListener(ctx) + +// Listen to different channels with different handlers +listener.Listen("user_events", func(channel, payload string) { + fmt.Printf("User event: %s\n", payload) +}) + +listener.Listen("order_events", func(channel, payload string) { + fmt.Printf("Order event: %s\n", payload) +}) + +listener.Listen("payment_events", func(channel, payload string) { + fmt.Printf("Payment event: %s\n", payload) +}) +``` + +### Unsubscribing + +```go +// Stop listening to a specific channel +err := listener.Unlisten("user_events") +if err != nil { + fmt.Printf("Failed to unlisten: %v\n", err) +} +``` + +### Checking Active Channels + +```go +// Get list of channels currently being listened to +channels := listener.Channels() +fmt.Printf("Listening to: %v\n", channels) +``` + +### Checking Connection Status + +```go +if listener.IsConnected() { + fmt.Println("Listener is connected") +} else { + fmt.Println("Listener is disconnected") +} +``` + +## Integration with DBManager + +When using the DBManager, you can access the listener through the PostgreSQL provider: + +```go +// Initialize DBManager +mgr, err := dbmanager.NewManager(dbmanager.FromConfig(cfg.DBManager)) +mgr.Connect(ctx) +defer mgr.Close() + +// Get PostgreSQL connection +conn, err := mgr.Get("primary") + +// Note: You'll need to cast to the underlying provider type +// This requires exposing the provider through the Connection interface +// or providing a helper method +``` + +## Use Cases + +### Cache Invalidation + +```go +listener.Listen("cache_invalidation", func(channel, payload string) { + // Parse the payload to determine what to invalidate + cache.Invalidate(payload) +}) +``` + +### Real-time Updates + +```go +listener.Listen("data_updates", func(channel, payload string) { + // Broadcast update to WebSocket clients + websocketBroadcast(payload) +}) +``` + +### Configuration Reload + +```go +listener.Listen("config_reload", func(channel, payload string) { + // Reload application configuration + config.Reload() +}) +``` + +### Distributed Locking + +```go +listener.Listen("lock_released", func(channel, payload string) { + // Attempt to acquire the lock + tryAcquireLock(payload) +}) +``` + +## Automatic Reconnection + +The listener automatically handles connection failures: + +1. When a connection error is detected, the listener initiates reconnection +2. Once reconnected, it automatically resubscribes to all previous channels +3. Notification handlers remain active throughout the reconnection process + +No manual intervention is required for reconnection. + +## Error Handling + +### Handler Panics + +If a notification handler panics, the panic is recovered and logged. The listener continues to function normally: + +```go +listener.Listen("events", func(channel, payload string) { + defer func() { + if r := recover(); r != nil { + log.Printf("Handler panic: %v", r) + } + }() + + // Your event processing logic + processEvent(payload) +}) +``` + +### Connection Errors + +Connection errors trigger automatic reconnection. Check logs for reconnection events when `EnableLogging` is true. + +## Thread Safety + +All `PostgresListener` methods are thread-safe and can be called concurrently from multiple goroutines. + +## Performance Considerations + +1. **Dedicated Connection**: The listener uses a dedicated PostgreSQL connection separate from the query connection pool +2. **Asynchronous Handlers**: Notification handlers run in separate goroutines to avoid blocking +3. **Lightweight**: NOTIFY/LISTEN has minimal overhead compared to polling + +## Comparison with Polling + +| Feature | NOTIFY/LISTEN | Polling | +|---------|---------------|---------| +| Latency | Low (near real-time) | High (depends on poll interval) | +| Database Load | Minimal | High (constant queries) | +| Scalability | Excellent | Poor | +| Complexity | Simple | Moderate | + +## Limitations + +1. **PostgreSQL Only**: This feature is specific to PostgreSQL and not available for other databases +2. **No Message Persistence**: Notifications are not stored; if no listener is connected, the message is lost +3. **Payload Limit**: Notification payload is limited to 8000 bytes in PostgreSQL +4. **No Guaranteed Delivery**: If a listener disconnects, in-flight notifications may be lost + +## Best Practices + +1. **Keep Handlers Fast**: Notification handlers should be quick; for heavy processing, send work to a queue +2. **Use JSON Payloads**: Encode structured data as JSON for easy parsing +3. **Handle Errors Gracefully**: Always recover from panics in handlers +4. **Close Properly**: Always close the provider to ensure the listener is properly shut down +5. **Monitor Connection Status**: Use `IsConnected()` for health checks + +## Example: Real-World Application + +```go +// Subscribe to various application events +listener, _ := provider.GetListener(ctx) + +// User registration events +listener.Listen("user_registered", func(channel, payload string) { + var event UserRegisteredEvent + json.Unmarshal([]byte(payload), &event) + + // Send welcome email + sendWelcomeEmail(event.UserID) + + // Invalidate user count cache + cache.Delete("user_count") +}) + +// Order placement events +listener.Listen("order_placed", func(channel, payload string) { + var event OrderPlacedEvent + json.Unmarshal([]byte(payload), &event) + + // Notify warehouse system + warehouse.ProcessOrder(event.OrderID) + + // Update inventory cache + cache.Invalidate("inventory:" + event.ProductID) +}) + +// Configuration changes +listener.Listen("config_updated", func(channel, payload string) { + // Reload configuration from database + appConfig.Reload() +}) +``` + +## Triggering Notifications from SQL + +You can trigger notifications directly from PostgreSQL triggers or functions: + +```sql +-- Example trigger to notify on new user +CREATE OR REPLACE FUNCTION notify_user_registered() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('user_registered', + json_build_object( + 'user_id', NEW.id, + 'email', NEW.email, + 'timestamp', NOW() + )::text + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER user_registered_trigger +AFTER INSERT ON users +FOR EACH ROW +EXECUTE FUNCTION notify_user_registered(); +``` + +## Additional Resources + +- [PostgreSQL NOTIFY Documentation](https://www.postgresql.org/docs/current/sql-notify.html) +- [PostgreSQL LISTEN Documentation](https://www.postgresql.org/docs/current/sql-listen.html) +- [pgx Driver Documentation](https://github.com/jackc/pgx) diff --git a/pkg/dbmanager/providers/mongodb.go b/pkg/dbmanager/providers/mongodb.go new file mode 100644 index 0000000..e832870 --- /dev/null +++ b/pkg/dbmanager/providers/mongodb.go @@ -0,0 +1,214 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/readpref" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// MongoProvider implements Provider for MongoDB databases +type MongoProvider struct { + client *mongo.Client + config ConnectionConfig +} + +// NewMongoProvider creates a new MongoDB provider +func NewMongoProvider() *MongoProvider { + return &MongoProvider{} +} + +// Connect establishes a MongoDB connection +func (p *MongoProvider) Connect(ctx context.Context, cfg ConnectionConfig) error { + // Build DSN + dsn, err := cfg.BuildDSN() + if err != nil { + return fmt.Errorf("failed to build DSN: %w", err) + } + + // Create client options + clientOpts := options.Client().ApplyURI(dsn) + + // Set connection pool size + if cfg.GetMaxOpenConns() != nil { + maxPoolSize := uint64(*cfg.GetMaxOpenConns()) + clientOpts.SetMaxPoolSize(maxPoolSize) + } + + if cfg.GetMaxIdleConns() != nil { + minPoolSize := uint64(*cfg.GetMaxIdleConns()) + clientOpts.SetMinPoolSize(minPoolSize) + } + + // Set timeouts + clientOpts.SetConnectTimeout(cfg.GetConnectTimeout()) + if cfg.GetQueryTimeout() > 0 { + clientOpts.SetTimeout(cfg.GetQueryTimeout()) + } + + // Set read preference if specified + if cfg.GetReadPreference() != "" { + rp, err := parseReadPreference(cfg.GetReadPreference()) + if err != nil { + return fmt.Errorf("invalid read preference: %w", err) + } + clientOpts.SetReadPreference(rp) + } + + // Connect with retry logic + var client *mongo.Client + var lastErr error + + retryAttempts := 3 + retryDelay := 1 * time.Second + + for attempt := 0; attempt < retryAttempts; attempt++ { + if attempt > 0 { + delay := calculateBackoff(attempt, retryDelay, 10*time.Second) + if cfg.GetEnableLogging() { + logger.Info("Retrying MongoDB connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay) + } + + select { + case <-time.After(delay): + case <-ctx.Done(): + return ctx.Err() + } + } + + // Create MongoDB client + client, err = mongo.Connect(ctx, clientOpts) + if err != nil { + lastErr = err + if cfg.GetEnableLogging() { + logger.Warn("Failed to connect to MongoDB", "error", err) + } + continue + } + + // Ping the database to verify connection + pingCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout()) + err = client.Ping(pingCtx, readpref.Primary()) + cancel() + + if err != nil { + lastErr = err + _ = client.Disconnect(ctx) + if cfg.GetEnableLogging() { + logger.Warn("Failed to ping MongoDB", "error", err) + } + continue + } + + // Connection successful + break + } + + if err != nil { + return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr) + } + + p.client = client + p.config = cfg + + if cfg.GetEnableLogging() { + logger.Info("MongoDB connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase()) + } + + return nil +} + +// Close closes the MongoDB connection +func (p *MongoProvider) Close() error { + if p.client == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := p.client.Disconnect(ctx) + if err != nil { + return fmt.Errorf("failed to close MongoDB connection: %w", err) + } + + if p.config.GetEnableLogging() { + logger.Info("MongoDB connection closed: name=%s", p.config.GetName()) + } + + p.client = nil + return nil +} + +// HealthCheck verifies the MongoDB connection is alive +func (p *MongoProvider) HealthCheck(ctx context.Context) error { + if p.client == nil { + return fmt.Errorf("MongoDB client is nil") + } + + // Use a short timeout for health checks + healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := p.client.Ping(healthCtx, readpref.Primary()); err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + return nil +} + +// GetNative returns an error for MongoDB (not a SQL database) +func (p *MongoProvider) GetNative() (*sql.DB, error) { + return nil, ErrNotSQLDatabase +} + +// GetMongo returns the MongoDB client +func (p *MongoProvider) GetMongo() (*mongo.Client, error) { + if p.client == nil { + return nil, fmt.Errorf("MongoDB client is not initialized") + } + return p.client, nil +} + +// Stats returns connection statistics for MongoDB +func (p *MongoProvider) Stats() *ConnectionStats { + if p.client == nil { + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "mongodb", + Connected: false, + } + } + + // MongoDB doesn't expose detailed connection pool stats like sql.DB + // We return basic stats + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "mongodb", + Connected: true, + } +} + +// parseReadPreference parses a read preference string into a readpref.ReadPref +func parseReadPreference(rp string) (*readpref.ReadPref, error) { + switch rp { + case "primary": + return readpref.Primary(), nil + case "primaryPreferred": + return readpref.PrimaryPreferred(), nil + case "secondary": + return readpref.Secondary(), nil + case "secondaryPreferred": + return readpref.SecondaryPreferred(), nil + case "nearest": + return readpref.Nearest(), nil + default: + return nil, fmt.Errorf("unknown read preference: %s", rp) + } +} diff --git a/pkg/dbmanager/providers/mssql.go b/pkg/dbmanager/providers/mssql.go new file mode 100644 index 0000000..bad58f9 --- /dev/null +++ b/pkg/dbmanager/providers/mssql.go @@ -0,0 +1,184 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/microsoft/go-mssqldb" // MSSQL driver + "go.mongodb.org/mongo-driver/mongo" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// MSSQLProvider implements Provider for Microsoft SQL Server databases +type MSSQLProvider struct { + db *sql.DB + config ConnectionConfig +} + +// NewMSSQLProvider creates a new MSSQL provider +func NewMSSQLProvider() *MSSQLProvider { + return &MSSQLProvider{} +} + +// Connect establishes a MSSQL connection +func (p *MSSQLProvider) Connect(ctx context.Context, cfg ConnectionConfig) error { + // Build DSN + dsn, err := cfg.BuildDSN() + if err != nil { + return fmt.Errorf("failed to build DSN: %w", err) + } + + // Connect with retry logic + var db *sql.DB + var lastErr error + + retryAttempts := 3 // Default retry attempts + retryDelay := 1 * time.Second + + for attempt := 0; attempt < retryAttempts; attempt++ { + if attempt > 0 { + delay := calculateBackoff(attempt, retryDelay, 10*time.Second) + if cfg.GetEnableLogging() { + logger.Info("Retrying MSSQL connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay) + } + + select { + case <-time.After(delay): + case <-ctx.Done(): + return ctx.Err() + } + } + + // Open database connection + db, err = sql.Open("sqlserver", dsn) + if err != nil { + lastErr = err + if cfg.GetEnableLogging() { + logger.Warn("Failed to open MSSQL connection", "error", err) + } + continue + } + + // Test the connection with context timeout + connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout()) + err = db.PingContext(connectCtx) + cancel() + + if err != nil { + lastErr = err + db.Close() + if cfg.GetEnableLogging() { + logger.Warn("Failed to ping MSSQL database", "error", err) + } + continue + } + + // Connection successful + break + } + + if err != nil { + return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr) + } + + // Configure connection pool + if cfg.GetMaxOpenConns() != nil { + db.SetMaxOpenConns(*cfg.GetMaxOpenConns()) + } + if cfg.GetMaxIdleConns() != nil { + db.SetMaxIdleConns(*cfg.GetMaxIdleConns()) + } + if cfg.GetConnMaxLifetime() != nil { + db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime()) + } + if cfg.GetConnMaxIdleTime() != nil { + db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime()) + } + + p.db = db + p.config = cfg + + if cfg.GetEnableLogging() { + logger.Info("MSSQL connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase()) + } + + return nil +} + +// Close closes the MSSQL connection +func (p *MSSQLProvider) Close() error { + if p.db == nil { + return nil + } + + err := p.db.Close() + if err != nil { + return fmt.Errorf("failed to close MSSQL connection: %w", err) + } + + if p.config.GetEnableLogging() { + logger.Info("MSSQL connection closed: name=%s", p.config.GetName()) + } + + p.db = nil + return nil +} + +// HealthCheck verifies the MSSQL connection is alive +func (p *MSSQLProvider) HealthCheck(ctx context.Context) error { + if p.db == nil { + return fmt.Errorf("database connection is nil") + } + + // Use a short timeout for health checks + healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := p.db.PingContext(healthCtx); err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + return nil +} + +// GetNative returns the native *sql.DB connection +func (p *MSSQLProvider) GetNative() (*sql.DB, error) { + if p.db == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + return p.db, nil +} + +// GetMongo returns an error for MSSQL (not a MongoDB connection) +func (p *MSSQLProvider) GetMongo() (*mongo.Client, error) { + return nil, ErrNotMongoDB +} + +// Stats returns connection pool statistics +func (p *MSSQLProvider) Stats() *ConnectionStats { + if p.db == nil { + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "mssql", + Connected: false, + } + } + + stats := p.db.Stats() + + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "mssql", + Connected: true, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration, + MaxIdleClosed: stats.MaxIdleClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} diff --git a/pkg/dbmanager/providers/postgres.go b/pkg/dbmanager/providers/postgres.go new file mode 100644 index 0000000..0391c23 --- /dev/null +++ b/pkg/dbmanager/providers/postgres.go @@ -0,0 +1,231 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "math" + "sync" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver + "go.mongodb.org/mongo-driver/mongo" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// PostgresProvider implements Provider for PostgreSQL databases +type PostgresProvider struct { + db *sql.DB + config ConnectionConfig + listener *PostgresListener + mu sync.Mutex +} + +// NewPostgresProvider creates a new PostgreSQL provider +func NewPostgresProvider() *PostgresProvider { + return &PostgresProvider{} +} + +// Connect establishes a PostgreSQL connection +func (p *PostgresProvider) Connect(ctx context.Context, cfg ConnectionConfig) error { + // Build DSN + dsn, err := cfg.BuildDSN() + if err != nil { + return fmt.Errorf("failed to build DSN: %w", err) + } + + // Connect with retry logic + var db *sql.DB + var lastErr error + + retryAttempts := 3 // Default retry attempts + retryDelay := 1 * time.Second + + for attempt := 0; attempt < retryAttempts; attempt++ { + if attempt > 0 { + delay := calculateBackoff(attempt, retryDelay, 10*time.Second) + if cfg.GetEnableLogging() { + logger.Info("Retrying PostgreSQL connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay) + } + + select { + case <-time.After(delay): + case <-ctx.Done(): + return ctx.Err() + } + } + + // Open database connection + db, err = sql.Open("pgx", dsn) + if err != nil { + lastErr = err + if cfg.GetEnableLogging() { + logger.Warn("Failed to open PostgreSQL connection", "error", err) + } + continue + } + + // Test the connection with context timeout + connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout()) + err = db.PingContext(connectCtx) + cancel() + + if err != nil { + lastErr = err + db.Close() + if cfg.GetEnableLogging() { + logger.Warn("Failed to ping PostgreSQL database", "error", err) + } + continue + } + + // Connection successful + break + } + + if err != nil { + return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr) + } + + // Configure connection pool + if cfg.GetMaxOpenConns() != nil { + db.SetMaxOpenConns(*cfg.GetMaxOpenConns()) + } + if cfg.GetMaxIdleConns() != nil { + db.SetMaxIdleConns(*cfg.GetMaxIdleConns()) + } + if cfg.GetConnMaxLifetime() != nil { + db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime()) + } + if cfg.GetConnMaxIdleTime() != nil { + db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime()) + } + + p.db = db + p.config = cfg + + if cfg.GetEnableLogging() { + logger.Info("PostgreSQL connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase()) + } + + return nil +} + +// Close closes the PostgreSQL connection +func (p *PostgresProvider) Close() error { + // Close listener if it exists + p.mu.Lock() + if p.listener != nil { + if err := p.listener.Close(); err != nil { + p.mu.Unlock() + return fmt.Errorf("failed to close listener: %w", err) + } + p.listener = nil + } + p.mu.Unlock() + + if p.db == nil { + return nil + } + + err := p.db.Close() + if err != nil { + return fmt.Errorf("failed to close PostgreSQL connection: %w", err) + } + + if p.config.GetEnableLogging() { + logger.Info("PostgreSQL connection closed: name=%s", p.config.GetName()) + } + + p.db = nil + return nil +} + +// HealthCheck verifies the PostgreSQL connection is alive +func (p *PostgresProvider) HealthCheck(ctx context.Context) error { + if p.db == nil { + return fmt.Errorf("database connection is nil") + } + + // Use a short timeout for health checks + healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := p.db.PingContext(healthCtx); err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + return nil +} + +// GetNative returns the native *sql.DB connection +func (p *PostgresProvider) GetNative() (*sql.DB, error) { + if p.db == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + return p.db, nil +} + +// GetMongo returns an error for PostgreSQL (not a MongoDB connection) +func (p *PostgresProvider) GetMongo() (*mongo.Client, error) { + return nil, ErrNotMongoDB +} + +// Stats returns connection pool statistics +func (p *PostgresProvider) Stats() *ConnectionStats { + if p.db == nil { + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "postgres", + Connected: false, + } + } + + stats := p.db.Stats() + + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "postgres", + Connected: true, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration, + MaxIdleClosed: stats.MaxIdleClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +// GetListener returns a PostgreSQL listener for NOTIFY/LISTEN functionality +// The listener is lazily initialized on first call and reused for subsequent calls +func (p *PostgresProvider) GetListener(ctx context.Context) (*PostgresListener, error) { + p.mu.Lock() + defer p.mu.Unlock() + + // Return existing listener if already created + if p.listener != nil { + return p.listener, nil + } + + // Create new listener + listener := NewPostgresListener(p.config) + + // Connect the listener + if err := listener.Connect(ctx); err != nil { + return nil, fmt.Errorf("failed to connect listener: %w", err) + } + + p.listener = listener + return p.listener, nil +} + +// calculateBackoff calculates exponential backoff delay +func calculateBackoff(attempt int, initial, maxDelay time.Duration) time.Duration { + delay := initial * time.Duration(math.Pow(2, float64(attempt))) + if delay > maxDelay { + delay = maxDelay + } + return delay +} diff --git a/pkg/dbmanager/providers/postgres_listener.go b/pkg/dbmanager/providers/postgres_listener.go new file mode 100644 index 0000000..c417c73 --- /dev/null +++ b/pkg/dbmanager/providers/postgres_listener.go @@ -0,0 +1,401 @@ +package providers + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// NotificationHandler is called when a notification is received +type NotificationHandler func(channel string, payload string) + +// PostgresListener manages PostgreSQL LISTEN/NOTIFY functionality +type PostgresListener struct { + config ConnectionConfig + conn *pgx.Conn + + // Channel subscriptions + channels map[string]NotificationHandler + mu sync.RWMutex + + // Lifecycle management + ctx context.Context + cancel context.CancelFunc + closed bool + closeMu sync.Mutex + reconnectC chan struct{} +} + +// NewPostgresListener creates a new PostgreSQL listener +func NewPostgresListener(cfg ConnectionConfig) *PostgresListener { + ctx, cancel := context.WithCancel(context.Background()) + return &PostgresListener{ + config: cfg, + channels: make(map[string]NotificationHandler), + ctx: ctx, + cancel: cancel, + reconnectC: make(chan struct{}, 1), + } +} + +// Connect establishes a dedicated connection for listening +func (l *PostgresListener) Connect(ctx context.Context) error { + dsn, err := l.config.BuildDSN() + if err != nil { + return fmt.Errorf("failed to build DSN: %w", err) + } + + // Parse connection config + connConfig, err := pgx.ParseConfig(dsn) + if err != nil { + return fmt.Errorf("failed to parse connection config: %w", err) + } + + // Connect with retry logic + var conn *pgx.Conn + var lastErr error + + retryAttempts := 3 + retryDelay := 1 * time.Second + + for attempt := 0; attempt < retryAttempts; attempt++ { + if attempt > 0 { + delay := calculateBackoff(attempt, retryDelay, 10*time.Second) + if l.config.GetEnableLogging() { + logger.Info("Retrying PostgreSQL listener connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay) + } + + select { + case <-time.After(delay): + case <-ctx.Done(): + return ctx.Err() + } + } + + conn, err = pgx.ConnectConfig(ctx, connConfig) + if err != nil { + lastErr = err + if l.config.GetEnableLogging() { + logger.Warn("Failed to connect PostgreSQL listener", "error", err) + } + continue + } + + // Test the connection + if err = conn.Ping(ctx); err != nil { + lastErr = err + conn.Close(ctx) + if l.config.GetEnableLogging() { + logger.Warn("Failed to ping PostgreSQL listener", "error", err) + } + continue + } + + // Connection successful + break + } + + if err != nil { + return fmt.Errorf("failed to connect listener after %d attempts: %w", retryAttempts, lastErr) + } + + l.mu.Lock() + l.conn = conn + l.mu.Unlock() + + // Start notification handler + go l.handleNotifications() + + // Start reconnection handler + go l.handleReconnection() + + if l.config.GetEnableLogging() { + logger.Info("PostgreSQL listener connected: name=%s", l.config.GetName()) + } + + return nil +} + +// Listen subscribes to a PostgreSQL notification channel +func (l *PostgresListener) Listen(channel string, handler NotificationHandler) error { + l.closeMu.Lock() + if l.closed { + l.closeMu.Unlock() + return fmt.Errorf("listener is closed") + } + l.closeMu.Unlock() + + l.mu.Lock() + defer l.mu.Unlock() + + if l.conn == nil { + return fmt.Errorf("listener connection is not initialized") + } + + // Execute LISTEN command + _, err := l.conn.Exec(l.ctx, fmt.Sprintf("LISTEN %s", pgx.Identifier{channel}.Sanitize())) + if err != nil { + return fmt.Errorf("failed to listen on channel %s: %w", channel, err) + } + + // Store the handler + l.channels[channel] = handler + + if l.config.GetEnableLogging() { + logger.Info("Listening on channel: name=%s, channel=%s", l.config.GetName(), channel) + } + + return nil +} + +// Unlisten unsubscribes from a PostgreSQL notification channel +func (l *PostgresListener) Unlisten(channel string) error { + l.closeMu.Lock() + if l.closed { + l.closeMu.Unlock() + return fmt.Errorf("listener is closed") + } + l.closeMu.Unlock() + + l.mu.Lock() + defer l.mu.Unlock() + + if l.conn == nil { + return fmt.Errorf("listener connection is not initialized") + } + + // Execute UNLISTEN command + _, err := l.conn.Exec(l.ctx, fmt.Sprintf("UNLISTEN %s", pgx.Identifier{channel}.Sanitize())) + if err != nil { + return fmt.Errorf("failed to unlisten from channel %s: %w", channel, err) + } + + // Remove the handler + delete(l.channels, channel) + + if l.config.GetEnableLogging() { + logger.Info("Unlistened from channel: name=%s, channel=%s", l.config.GetName(), channel) + } + + return nil +} + +// Notify sends a notification to a PostgreSQL channel +func (l *PostgresListener) Notify(ctx context.Context, channel string, payload string) error { + l.closeMu.Lock() + if l.closed { + l.closeMu.Unlock() + return fmt.Errorf("listener is closed") + } + l.closeMu.Unlock() + + l.mu.RLock() + conn := l.conn + l.mu.RUnlock() + + if conn == nil { + return fmt.Errorf("listener connection is not initialized") + } + + // Execute NOTIFY command + _, err := conn.Exec(ctx, "SELECT pg_notify($1, $2)", channel, payload) + if err != nil { + return fmt.Errorf("failed to notify channel %s: %w", channel, err) + } + + return nil +} + +// Close closes the listener and all subscriptions +func (l *PostgresListener) Close() error { + l.closeMu.Lock() + if l.closed { + l.closeMu.Unlock() + return nil + } + l.closed = true + l.closeMu.Unlock() + + // Cancel context to stop background goroutines + l.cancel() + + l.mu.Lock() + defer l.mu.Unlock() + + if l.conn == nil { + return nil + } + + // Unlisten from all channels + for channel := range l.channels { + _, _ = l.conn.Exec(context.Background(), fmt.Sprintf("UNLISTEN %s", pgx.Identifier{channel}.Sanitize())) + } + + // Close connection + err := l.conn.Close(context.Background()) + if err != nil { + return fmt.Errorf("failed to close listener connection: %w", err) + } + + l.conn = nil + l.channels = make(map[string]NotificationHandler) + + if l.config.GetEnableLogging() { + logger.Info("PostgreSQL listener closed: name=%s", l.config.GetName()) + } + + return nil +} + +// handleNotifications processes incoming notifications +func (l *PostgresListener) handleNotifications() { + for { + select { + case <-l.ctx.Done(): + return + default: + } + + l.mu.RLock() + conn := l.conn + l.mu.RUnlock() + + if conn == nil { + // Connection not available, wait for reconnection + time.Sleep(100 * time.Millisecond) + continue + } + + // Wait for notification with timeout + ctx, cancel := context.WithTimeout(l.ctx, 5*time.Second) + notification, err := conn.WaitForNotification(ctx) + cancel() + + if err != nil { + // Check if context was cancelled + if l.ctx.Err() != nil { + return + } + + // Check if it's a connection error + if pgconn.Timeout(err) { + // Timeout is normal, continue waiting + continue + } + + // Connection error, trigger reconnection + if l.config.GetEnableLogging() { + logger.Warn("Notification error, triggering reconnection", "error", err) + } + select { + case l.reconnectC <- struct{}{}: + default: + } + time.Sleep(1 * time.Second) + continue + } + + // Process notification + l.mu.RLock() + handler, exists := l.channels[notification.Channel] + l.mu.RUnlock() + + if exists && handler != nil { + // Call handler in a goroutine to avoid blocking + go func(ch, payload string) { + defer func() { + if r := recover(); r != nil { + if l.config.GetEnableLogging() { + logger.Error("Notification handler panic: channel=%s, error=%v", ch, r) + } + } + }() + handler(ch, payload) + }(notification.Channel, notification.Payload) + } + } +} + +// handleReconnection manages automatic reconnection +func (l *PostgresListener) handleReconnection() { + for { + select { + case <-l.ctx.Done(): + return + case <-l.reconnectC: + if l.config.GetEnableLogging() { + logger.Info("Attempting to reconnect listener: name=%s", l.config.GetName()) + } + + // Close existing connection + l.mu.Lock() + if l.conn != nil { + l.conn.Close(context.Background()) + l.conn = nil + } + + // Save current subscriptions + channels := make(map[string]NotificationHandler) + for ch, handler := range l.channels { + channels[ch] = handler + } + l.mu.Unlock() + + // Attempt reconnection + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := l.Connect(ctx) + cancel() + + if err != nil { + if l.config.GetEnableLogging() { + logger.Error("Failed to reconnect listener: name=%s, error=%v", l.config.GetName(), err) + } + // Retry after delay + time.Sleep(5 * time.Second) + select { + case l.reconnectC <- struct{}{}: + default: + } + continue + } + + // Resubscribe to all channels + for channel, handler := range channels { + if err := l.Listen(channel, handler); err != nil { + if l.config.GetEnableLogging() { + logger.Error("Failed to resubscribe to channel: name=%s, channel=%s, error=%v", l.config.GetName(), channel, err) + } + } + } + + if l.config.GetEnableLogging() { + logger.Info("Listener reconnected successfully: name=%s", l.config.GetName()) + } + } + } +} + +// IsConnected returns true if the listener is connected +func (l *PostgresListener) IsConnected() bool { + l.mu.RLock() + defer l.mu.RUnlock() + return l.conn != nil +} + +// Channels returns the list of channels currently being listened to +func (l *PostgresListener) Channels() []string { + l.mu.RLock() + defer l.mu.RUnlock() + + channels := make([]string, 0, len(l.channels)) + for ch := range l.channels { + channels = append(channels, ch) + } + return channels +} diff --git a/pkg/dbmanager/providers/postgres_listener_example_test.go b/pkg/dbmanager/providers/postgres_listener_example_test.go new file mode 100644 index 0000000..03f0452 --- /dev/null +++ b/pkg/dbmanager/providers/postgres_listener_example_test.go @@ -0,0 +1,228 @@ +package providers_test + +import ( + "context" + "fmt" + "time" + + "github.com/bitechdev/ResolveSpec/pkg/dbmanager" + "github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers" +) + +// ExamplePostgresListener_basic demonstrates basic LISTEN/NOTIFY usage +func ExamplePostgresListener_basic() { + // Create a connection config + cfg := &dbmanager.ConnectionConfig{ + Name: "example", + Type: dbmanager.DatabaseTypePostgreSQL, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Database: "testdb", + ConnectTimeout: 10 * time.Second, + EnableLogging: true, + } + + // Create and connect PostgreSQL provider + provider := providers.NewPostgresProvider() + ctx := context.Background() + + if err := provider.Connect(ctx, cfg); err != nil { + panic(fmt.Sprintf("Failed to connect: %v", err)) + } + defer provider.Close() + + // Get listener + listener, err := provider.GetListener(ctx) + if err != nil { + panic(fmt.Sprintf("Failed to get listener: %v", err)) + } + + // Subscribe to a channel with a handler + err = listener.Listen("user_events", func(channel, payload string) { + fmt.Printf("Received notification on %s: %s\n", channel, payload) + }) + if err != nil { + panic(fmt.Sprintf("Failed to listen: %v", err)) + } + + // Send a notification + err = listener.Notify(ctx, "user_events", `{"event":"user_created","user_id":123}`) + if err != nil { + panic(fmt.Sprintf("Failed to notify: %v", err)) + } + + // Wait for notification to be processed + time.Sleep(100 * time.Millisecond) + + // Unsubscribe from the channel + if err := listener.Unlisten("user_events"); err != nil { + panic(fmt.Sprintf("Failed to unlisten: %v", err)) + } +} + +// ExamplePostgresListener_multipleChannels demonstrates listening to multiple channels +func ExamplePostgresListener_multipleChannels() { + cfg := &dbmanager.ConnectionConfig{ + Name: "example", + Type: dbmanager.DatabaseTypePostgreSQL, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Database: "testdb", + ConnectTimeout: 10 * time.Second, + EnableLogging: false, + } + + provider := providers.NewPostgresProvider() + ctx := context.Background() + + if err := provider.Connect(ctx, cfg); err != nil { + panic(fmt.Sprintf("Failed to connect: %v", err)) + } + defer provider.Close() + + listener, err := provider.GetListener(ctx) + if err != nil { + panic(fmt.Sprintf("Failed to get listener: %v", err)) + } + + // Listen to multiple channels + channels := []string{"orders", "payments", "notifications"} + for _, ch := range channels { + channel := ch // Capture for closure + err := listener.Listen(channel, func(ch, payload string) { + fmt.Printf("[%s] %s\n", ch, payload) + }) + if err != nil { + panic(fmt.Sprintf("Failed to listen on %s: %v", channel, err)) + } + } + + // Send notifications to different channels + listener.Notify(ctx, "orders", "New order #12345") + listener.Notify(ctx, "payments", "Payment received $99.99") + listener.Notify(ctx, "notifications", "Welcome email sent") + + // Wait for notifications + time.Sleep(200 * time.Millisecond) + + // Check active channels + activeChannels := listener.Channels() + fmt.Printf("Listening to %d channels: %v\n", len(activeChannels), activeChannels) +} + +// ExamplePostgresListener_withDBManager demonstrates usage with DBManager +func ExamplePostgresListener_withDBManager() { + // This example shows how to use the listener with the full DBManager + + // Assume we have a DBManager instance and get a connection + // conn, _ := dbMgr.Get("primary") + + // Get the underlying provider (this would need to be exposed via the Connection interface) + // For now, this is a conceptual example + + ctx := context.Background() + + // Create provider directly for demonstration + cfg := &dbmanager.ConnectionConfig{ + Name: "primary", + Type: dbmanager.DatabaseTypePostgreSQL, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Database: "myapp", + ConnectTimeout: 10 * time.Second, + } + + provider := providers.NewPostgresProvider() + if err := provider.Connect(ctx, cfg); err != nil { + panic(err) + } + defer provider.Close() + + // Get listener + listener, err := provider.GetListener(ctx) + if err != nil { + panic(err) + } + + // Subscribe to application events + listener.Listen("cache_invalidation", func(channel, payload string) { + fmt.Printf("Cache invalidation request: %s\n", payload) + // Handle cache invalidation logic here + }) + + listener.Listen("config_reload", func(channel, payload string) { + fmt.Printf("Configuration reload request: %s\n", payload) + // Handle configuration reload logic here + }) + + // Simulate receiving notifications + listener.Notify(ctx, "cache_invalidation", "user:123") + listener.Notify(ctx, "config_reload", "database") + + time.Sleep(100 * time.Millisecond) +} + +// ExamplePostgresListener_errorHandling demonstrates error handling and reconnection +func ExamplePostgresListener_errorHandling() { + cfg := &dbmanager.ConnectionConfig{ + Name: "example", + Type: dbmanager.DatabaseTypePostgreSQL, + Host: "localhost", + Port: 5432, + User: "postgres", + Password: "password", + Database: "testdb", + ConnectTimeout: 10 * time.Second, + EnableLogging: true, + } + + provider := providers.NewPostgresProvider() + ctx := context.Background() + + if err := provider.Connect(ctx, cfg); err != nil { + panic(fmt.Sprintf("Failed to connect: %v", err)) + } + defer provider.Close() + + listener, err := provider.GetListener(ctx) + if err != nil { + panic(fmt.Sprintf("Failed to get listener: %v", err)) + } + + // The listener automatically reconnects if the connection is lost + // Subscribe with error handling in the callback + err = listener.Listen("critical_events", func(channel, payload string) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("Handler panic recovered: %v\n", r) + } + }() + + // Process the event + fmt.Printf("Processing critical event: %s\n", payload) + + // If processing fails, the panic will be caught by the defer above + // The listener will continue to function normally + }) + + if err != nil { + fmt.Printf("Failed to listen: %v\n", err) + return + } + + // Check if listener is connected + if listener.IsConnected() { + fmt.Println("Listener is connected and ready") + } + + // Send a notification + listener.Notify(ctx, "critical_events", "system_alert") + + time.Sleep(100 * time.Millisecond) +} diff --git a/pkg/dbmanager/providers/provider.go b/pkg/dbmanager/providers/provider.go new file mode 100644 index 0000000..65dbc6f --- /dev/null +++ b/pkg/dbmanager/providers/provider.go @@ -0,0 +1,83 @@ +package providers + +import ( + "context" + "database/sql" + "errors" + "time" + + "go.mongodb.org/mongo-driver/mongo" +) + +// Common errors +var ( + // ErrNotSQLDatabase is returned when attempting SQL operations on a non-SQL database + ErrNotSQLDatabase = errors.New("not a SQL database") + + // ErrNotMongoDB is returned when attempting MongoDB operations on a non-MongoDB connection + ErrNotMongoDB = errors.New("not a MongoDB connection") +) + +// ConnectionStats contains statistics about a database connection +type ConnectionStats struct { + Name string + Type string // Database type as string to avoid circular dependency + Connected bool + LastHealthCheck time.Time + HealthCheckStatus string + + // SQL connection pool stats + OpenConnections int + InUse int + Idle int + WaitCount int64 + WaitDuration time.Duration + MaxIdleClosed int64 + MaxLifetimeClosed int64 +} + +// ConnectionConfig is a minimal interface for configuration +// The actual implementation is in dbmanager package +type ConnectionConfig interface { + BuildDSN() (string, error) + GetName() string + GetType() string + GetHost() string + GetPort() int + GetUser() string + GetPassword() string + GetDatabase() string + GetFilePath() string + GetConnectTimeout() time.Duration + GetQueryTimeout() time.Duration + GetEnableLogging() bool + GetEnableMetrics() bool + GetMaxOpenConns() *int + GetMaxIdleConns() *int + GetConnMaxLifetime() *time.Duration + GetConnMaxIdleTime() *time.Duration + GetReadPreference() string +} + +// Provider creates and manages the underlying database connection +type Provider interface { + // Connect establishes the database connection + Connect(ctx context.Context, cfg ConnectionConfig) error + + // Close closes the connection + Close() error + + // HealthCheck verifies the connection is alive + HealthCheck(ctx context.Context) error + + // GetNative returns the native *sql.DB (SQL databases only) + // Returns an error for non-SQL databases + GetNative() (*sql.DB, error) + + // GetMongo returns the MongoDB client (MongoDB only) + // Returns an error for non-MongoDB databases + GetMongo() (*mongo.Client, error) + + // Stats returns connection statistics + Stats() *ConnectionStats +} diff --git a/pkg/dbmanager/providers/sqlite.go b/pkg/dbmanager/providers/sqlite.go new file mode 100644 index 0000000..3ce5c99 --- /dev/null +++ b/pkg/dbmanager/providers/sqlite.go @@ -0,0 +1,177 @@ +package providers + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "github.com/glebarez/sqlite" // Pure Go SQLite driver + "go.mongodb.org/mongo-driver/mongo" + + "github.com/bitechdev/ResolveSpec/pkg/logger" +) + +// SQLiteProvider implements Provider for SQLite databases +type SQLiteProvider struct { + db *sql.DB + config ConnectionConfig +} + +// NewSQLiteProvider creates a new SQLite provider +func NewSQLiteProvider() *SQLiteProvider { + return &SQLiteProvider{} +} + +// Connect establishes a SQLite connection +func (p *SQLiteProvider) Connect(ctx context.Context, cfg ConnectionConfig) error { + // Build DSN + dsn, err := cfg.BuildDSN() + if err != nil { + return fmt.Errorf("failed to build DSN: %w", err) + } + + // Open database connection + db, err := sql.Open("sqlite", dsn) + if err != nil { + return fmt.Errorf("failed to open SQLite connection: %w", err) + } + + // Test the connection with context timeout + connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout()) + err = db.PingContext(connectCtx) + cancel() + + if err != nil { + db.Close() + return fmt.Errorf("failed to ping SQLite database: %w", err) + } + + // Configure connection pool + // Note: SQLite works best with MaxOpenConns=1 for write operations + // but can handle multiple readers + if cfg.GetMaxOpenConns() != nil { + db.SetMaxOpenConns(*cfg.GetMaxOpenConns()) + } else { + // Default to 1 for SQLite to avoid "database is locked" errors + db.SetMaxOpenConns(1) + } + + if cfg.GetMaxIdleConns() != nil { + db.SetMaxIdleConns(*cfg.GetMaxIdleConns()) + } + if cfg.GetConnMaxLifetime() != nil { + db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime()) + } + if cfg.GetConnMaxIdleTime() != nil { + db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime()) + } + + // Enable WAL mode for better concurrent access + _, err = db.ExecContext(ctx, "PRAGMA journal_mode=WAL") + if err != nil { + if cfg.GetEnableLogging() { + logger.Warn("Failed to enable WAL mode for SQLite", "error", err) + } + // Don't fail connection if WAL mode cannot be enabled + } + + // Set busy timeout to handle locked database + _, err = db.ExecContext(ctx, "PRAGMA busy_timeout=5000") + if err != nil { + if cfg.GetEnableLogging() { + logger.Warn("Failed to set busy timeout for SQLite", "error", err) + } + } + + p.db = db + p.config = cfg + + if cfg.GetEnableLogging() { + logger.Info("SQLite connection established: name=%s, filepath=%s", cfg.GetName(), cfg.GetFilePath()) + } + + return nil +} + +// Close closes the SQLite connection +func (p *SQLiteProvider) Close() error { + if p.db == nil { + return nil + } + + err := p.db.Close() + if err != nil { + return fmt.Errorf("failed to close SQLite connection: %w", err) + } + + if p.config.GetEnableLogging() { + logger.Info("SQLite connection closed: name=%s", p.config.GetName()) + } + + p.db = nil + return nil +} + +// HealthCheck verifies the SQLite connection is alive +func (p *SQLiteProvider) HealthCheck(ctx context.Context) error { + if p.db == nil { + return fmt.Errorf("database connection is nil") + } + + // Use a short timeout for health checks + healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // Execute a simple query to verify the database is accessible + var result int + err := p.db.QueryRowContext(healthCtx, "SELECT 1").Scan(&result) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + if result != 1 { + return fmt.Errorf("health check returned unexpected result: %d", result) + } + + return nil +} + +// GetNative returns the native *sql.DB connection +func (p *SQLiteProvider) GetNative() (*sql.DB, error) { + if p.db == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + return p.db, nil +} + +// GetMongo returns an error for SQLite (not a MongoDB connection) +func (p *SQLiteProvider) GetMongo() (*mongo.Client, error) { + return nil, ErrNotMongoDB +} + +// Stats returns connection pool statistics +func (p *SQLiteProvider) Stats() *ConnectionStats { + if p.db == nil { + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "sqlite", + Connected: false, + } + } + + stats := p.db.Stats() + + return &ConnectionStats{ + Name: p.config.GetName(), + Type: "sqlite", + Connected: true, + OpenConnections: stats.OpenConnections, + InUse: stats.InUse, + Idle: stats.Idle, + WaitCount: stats.WaitCount, + WaitDuration: stats.WaitDuration, + MaxIdleClosed: stats.MaxIdleClosed, + MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +}