// Package database provides the database/sql lifecycle used by GoOmni projects. // It deliberately does not import a database driver; each application chooses one. package database import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "git.yuntuo.hk/k2safe/go-omni/internal/configdiag" ) type Config struct { Driver string `json:"driver"` DSN string `json:"dsn"` MaxOpenConns int `json:"maxOpenConns"` MaxIdleConns int `json:"maxIdleConns"` ConnMaxLifetime time.Duration `json:"connMaxLifetime"` ConnMaxIdleTime time.Duration `json:"connMaxIdleTime"` PingTimeout time.Duration `json:"pingTimeout"` } // MarshalJSON omits the connection credential from serialized diagnostics. func (c Config) MarshalJSON() ([]byte, error) { type plain Config safe := plain(c) safe.DSN = "" return json.Marshal(safe) } func DefaultConfig() Config { return DefaultPoolConfig().config() } // PoolConfig returns driver-independent connection pool settings. func (c Config) PoolConfig() PoolConfig { return PoolConfig{ MaxOpenConns: c.MaxOpenConns, MaxIdleConns: c.MaxIdleConns, ConnMaxLifetime: c.ConnMaxLifetime, ConnMaxIdleTime: c.ConnMaxIdleTime, PingTimeout: c.PingTimeout, } } func (c Config) Validate() error { if strings.TrimSpace(c.Driver) == "" { return configdiag.New("driver", "is required") } if strings.TrimSpace(c.DSN) == "" { return configdiag.New("dsn", "is required") } return c.PoolConfig().Validate() } // DB owns a connection pool and exposes the embedded database/sql methods. type DB struct { *sql.DB config Config } func Open(ctx context.Context, cfg Config) (*DB, error) { if ctx == nil { ctx = context.Background() } if err := cfg.Validate(); err != nil { return nil, err } db, err := sql.Open(cfg.Driver, cfg.DSN) if err != nil { return nil, fmt.Errorf("open database: %w", err) } return initializePool(ctx, db, cfg) } // initializePool is the shared ownership boundary for both creation paths. // All configuration is validated before creating the sql.DB. func initializePool(ctx context.Context, db *sql.DB, cfg Config) (*DB, error) { db.SetMaxOpenConns(cfg.MaxOpenConns) db.SetMaxIdleConns(cfg.MaxIdleConns) // Zero explicitly disables idle connections. db.SetConnMaxLifetime(cfg.ConnMaxLifetime) db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout) defer cancel() if err := db.PingContext(checkCtx); err != nil { pingErr := fmt.Errorf("ping database: %w", err) if closeErr := db.Close(); closeErr != nil { return nil, errors.Join(pingErr, fmt.Errorf("close database after failed ping: %w", closeErr)) } return nil, pingErr } return &DB{DB: db, config: cfg}, nil } // Config returns the original DSN configuration. Pools opened with a Connector // have empty Driver/DSN fields; their connector cannot be reconstructed from Config. func (db *DB) Config() Config { return db.config } func (db *DB) Health(ctx context.Context) error { if db == nil || db.DB == nil { return errors.New("database is not initialized") } if ctx == nil { ctx = context.Background() } checkCtx, cancel := context.WithTimeout(ctx, db.config.PingTimeout) defer cancel() return db.PingContext(checkCtx) } // InTx commits only when work returns nil. Any work error or panic rolls back. func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) { if db == nil || db.DB == nil { return errors.New("database is not initialized") } tx, err := db.BeginTx(ctx, options) if err != nil { return fmt.Errorf("begin transaction: %w", err) } defer func() { rollbackErr := tx.Rollback() if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr)) } }() if err = work(tx); err != nil { return err } if err = tx.Commit(); err != nil { return fmt.Errorf("commit transaction: %w", err) } return nil }