Files
summercms/modules/lagoon/connection.go
Jakub Zych e6b20e2cb6 feat(conga,lagoon): River and GORM log through diary components
- River's logger is diary.For("river") (warn by default), conga's own lines diary.For("conga"); an app-published logger still wins
- GORM logs through logger.NewSlogLogger(diary.For("db")): warn level, record-not-found ignored, parameterized SQL traces only at verbosity 3
- Smoke tests TestRiverLoggerUsesDiarySmoke and TestGormLoggerUsesDiarySmoke
2026-10-21 11:51:57 +02:00

139 lines
4.3 KiB
Go

package lagoon
import (
"context"
"database/sql"
"fmt"
"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/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// 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"))
}
// Publish stores the shared SQL pool and GORM handle on the app. Both
// handles refer to the same *sql.DB. It then runs the callbacks queued by
// OnDatabase and returns the first error.
func Publish(app *backpack.App, sqlDB *sql.DB, gdb *gorm.DB) error {
if app == nil {
return fmt.Errorf("lagoon: app is nil")
}
if sqlDB == nil || gdb == nil {
return fmt.Errorf("lagoon: database handles are nil")
}
if err := app.Publish(sqlDB); err != nil {
return err
}
if err := app.Publish(gdb); err != nil {
return err
}
return runDatabaseHooks(app, sqlDB, gdb)
}