Files
summercms/modules/lagoon/ondatabase.go
Jakub Zych 6c1f94e57c feat(11-01): add lagoon.OnDatabase and after-commit transactions
- OnDatabase runs a callback once the database is published (now, or when
  lagoon.Publish runs), so GORM callbacks registered at Boot also install
  under serve, where Boot runs before the database is opened
- Transaction runs AfterCommit callbacks in order after a successful commit;
  nested calls are savepoints whose callbacks drop with them
- the lagoon:after_commit GORM callback flushes single-statement AfterCommit
  work after GORM's own commit; outside a transaction it runs immediately
2026-09-29 15:25:51 +02:00

96 lines
2.5 KiB
Go

package lagoon
import (
"database/sql"
"fmt"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
"gorm.io/gorm"
)
// databaseHooks is the per-app queue of OnDatabase callbacks waiting for
// Publish. It lives on the app, never in a package variable.
type databaseHooks struct {
mu sync.Mutex
pending []func(*sql.DB, *gorm.DB) error
}
// OnDatabase runs fn with the shared pool and GORM handle as soon as the
// database is available: immediately when the app has already published a
// *gorm.DB, otherwise when Publish runs. Plugins register GORM callbacks
// through it from Boot, which runs before the serve command publishes the
// database. The error of an immediate call is returned; the error of a
// queued call is returned by Publish.
func OnDatabase(app *backpack.App, fn func(sqlDB *sql.DB, gdb *gorm.DB) error) error {
if app == nil {
return fmt.Errorf("lagoon: app is nil")
}
if fn == nil {
return fmt.Errorf("lagoon: database hook is nil")
}
h, err := hooksFor(app)
if err != nil {
return err
}
h.mu.Lock()
sqlDB, gdb, ok := publishedDB(app)
if !ok {
h.pending = append(h.pending, fn)
h.mu.Unlock()
return nil
}
h.mu.Unlock()
return fn(sqlDB, gdb)
}
// runDatabaseHooks drains the OnDatabase queue once the handles are
// published and returns the first error.
func runDatabaseHooks(app *backpack.App, sqlDB *sql.DB, gdb *gorm.DB) error {
h, ok := app.Lookup[*databaseHooks]()
if !ok || h == nil {
return nil
}
h.mu.Lock()
pending := h.pending
h.pending = nil
h.mu.Unlock()
for _, fn := range pending {
if err := fn(sqlDB, gdb); err != nil {
return fmt.Errorf("lagoon: database hook: %w", err)
}
}
return nil
}
func hooksFor(app *backpack.App) (*databaseHooks, error) {
if h, ok := app.Lookup[*databaseHooks](); ok && h != nil {
return h, nil
}
h := &databaseHooks{}
if err := app.Publish(h); err != nil {
if existing, ok := app.Lookup[*databaseHooks](); ok && existing != nil {
return existing, nil
}
return nil, fmt.Errorf("lagoon: %w", err)
}
return h, nil
}
// publishedDB returns the published handles. A published *gorm.DB is
// enough: its own pool stands in when no *sql.DB was published.
func publishedDB(app *backpack.App) (*sql.DB, *gorm.DB, bool) {
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, nil, false
}
if sqlDB, ok := app.Lookup[*sql.DB](); ok && sqlDB != nil {
return sqlDB, gdb, true
}
sqlDB, err := gdb.DB()
if err != nil {
return nil, nil, false
}
return sqlDB, gdb, true
}