package lagoon import ( "context" "database/sql" "fmt" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/diary" "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) // moduleLog is lagoon's logger: records carry component=lagoon and follow // log.components.lagoon (diary). var moduleLog = diary.For("lagoon") // Open pings dsn through pgx stdlib and returns that exact *sql.DB plus a // GORM handle opened on it. // // The job worker in the conga module opens its own single-connection // pgxpool.Pool for River LISTEN/NOTIFY. Do not create that listener pool // here; application queries, including River's, share this *sql.DB. func Open(ctx context.Context, dsn string) (*sql.DB, *gorm.DB, error) { dsn = strings.TrimSpace(dsn) if dsn == "" { return nil, nil, fmt.Errorf("lagoon: database.dsn is empty (set SUMMER_DATABASE__DSN)") } sqlDB, err := sql.Open("pgx", dsn) if err != nil { return nil, nil, fmt.Errorf("lagoon: open postgres: %w", err) } if err := sqlDB.PingContext(ctx); err != nil { _ = sqlDB.Close() return nil, nil, fmt.Errorf("lagoon: ping postgres: %w", err) } gdb, err := gormFromSQL(sqlDB) if err != nil { _ = sqlDB.Close() return nil, nil, err } return sqlDB, gdb, nil } // Use pings an existing pool and returns a GORM handle opened on that exact // *sql.DB. Callers that already hold a pool (tests, the app boot seam) must // not open a second connection. func Use(ctx context.Context, sqlDB *sql.DB) (*gorm.DB, error) { if sqlDB == nil { return nil, fmt.Errorf("lagoon: sql db is nil") } if err := sqlDB.PingContext(ctx); err != nil { return nil, fmt.Errorf("lagoon: ping postgres: %w", err) } return gormFromSQL(sqlDB) } // gormLogger routes GORM's lines through diary as component db instead of // GORM's default stdout logger: slow queries (over 200ms) and errors at warn, // record-not-found ignored, and SQL traces only at verbosity 3 (-vvv), with // parameterized queries so bound values never reach a log line. func gormLogger() logger.Interface { level := logger.Warn if diary.Verbosity() >= 3 { level = logger.Info } return logger.NewSlogLogger(diary.For("db"), logger.Config{ SlowThreshold: 200 * time.Millisecond, LogLevel: level, IgnoreRecordNotFoundError: true, // Log SQL with placeholders, never with the bound values. ParameterizedQueries: true, }) } func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) { gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{Logger: gormLogger()}) if err != nil { return nil, fmt.Errorf("lagoon: gorm open: %w", err) } got, err := gdb.DB() if err != nil { return nil, fmt.Errorf("lagoon: gorm sql handle: %w", err) } if got != sqlDB { return nil, fmt.Errorf("lagoon: GORM is not using the shared *sql.DB") } if err := registerAfterCommit(gdb); err != nil { return nil, fmt.Errorf("lagoon: register %s: %w", AfterCommitCallback, err) } return gdb, nil } // OpenFromApp reads database.dsn from app config and opens the shared pool. // It also loads app.key via LoadAppKey and PublishEncryptionKeys so Encrypted // columns do not re-read config on every row. func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) { if app == nil || app.Config == nil { return nil, nil, fmt.Errorf("lagoon: app config is missing") } sqlDB, gdb, err := Open(ctx, DSN(app.Config)) if err != nil { return nil, nil, err } if err := loadEncryptionKeysFromApp(app); err != nil { _ = sqlDB.Close() return nil, nil, err } return sqlDB, gdb, nil } // DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN). func DSN(cfg *compass.Config) string { if cfg == nil { return "" } return strings.TrimSpace(cfg.String("database.dsn")) } // DatabaseLabel describes dsn for a person without its secrets: host:port/name // parsed with pgconn.ParseConfig (a Unix socket directory reads "socket //