feat(12.2-01): add deferred:purge, its daily framework schedule and relation child hooks

- deferred:purge [--days] in lagoon.RuntimeCommands (purge_days, default 5)
- lagoon.FrameworkSchedule entry at purge_at (default 03:00, empty disables)
- conga prepends framework entries as summercms.lagoon[i]:<command>
- pact.Relation{Before,After}{Create,Update,Delete} optional hooks
- lagoon, conga and pact READMEs, scheduling and setup docs
This commit is contained in:
Jakub Zych
2026-10-02 17:45:41 +02:00
parent f48a886f94
commit 8818d7b023
11 changed files with 378 additions and 8 deletions

View File

@@ -3,16 +3,23 @@ package lagoon
import (
"context"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// RuntimeCommands returns migrate, migrate:rollback, migrate:status, and key:generate.
// Serve is registered separately via surf.ServeCommand.
// RuntimeCommands returns migrate, migrate:rollback, migrate:status,
// deferred:purge and key:generate. Serve is registered separately via
// surf.ServeCommand.
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
return []bonfire.Command{
{
@@ -71,10 +78,155 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
})
},
},
{
Name: "deferred:purge",
Description: "Delete expired deferred bindings with their pending uploads and created records",
Flags: []bonfire.Flag{{
Name: "days",
Description: "Purge bindings older than this many days",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
days, err := purgeDays(app, in)
if err != nil {
return err
}
return withDB(ctx, app, func(gdb *gorm.DB) error {
models, err := deferredModels(gdb, plugins)
if err != nil {
return err
}
bucket, closeBucket, err := purgeBucket(ctx, app)
if err != nil {
return err
}
defer closeBucket()
res, err := PurgeDeferred(ctx, gdb, bucket, PurgeOptions{
Before: time.Now().Add(-time.Duration(days) * 24 * time.Hour),
Models: func(slaveType string) (any, bool) {
m, ok := models[slaveType]
return m, ok
},
})
if err != nil {
return err
}
out.Success(fmt.Sprintf("purged %d deferred bindings older than %d days (%d files, %d children, %d skipped)",
res.Bindings, days, res.Files, res.Children, res.Skipped))
return nil
})
},
},
KeyGenerateCommand(),
}
}
// purgeDays is the --days flag, else database.deferred_bindings.purge_days,
// else WinterCMS's five days. A negative or non-integer value is an error.
func purgeDays(app *backpack.App, in bonfire.Input) (int, error) {
if in != nil {
if raw, ok := in.Flag("days"); ok {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 0 {
return 0, fmt.Errorf("lagoon: deferred:purge --days %q must be a whole number of days, 0 or more", raw)
}
return n, nil
}
}
if app == nil || app.Config == nil {
return defaultPurgeDays, nil
}
raw, ok := app.Config.Lookup(purgeDaysKey)
if !ok || raw == nil {
return defaultPurgeDays, nil
}
var n int
switch v := raw.(type) {
case int:
n = v
case int64:
n = int(v)
case uint64:
n = int(v)
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("lagoon: %s %v must be a whole number of days", purgeDaysKey, raw)
}
n = int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("lagoon: %s %q must be a whole number of days", purgeDaysKey, v)
}
n = parsed
default:
return 0, fmt.Errorf("lagoon: %s %v must be a whole number of days", purgeDaysKey, raw)
}
if n < 0 {
return 0, fmt.Errorf("lagoon: %s %d must not be negative", purgeDaysKey, n)
}
return n, nil
}
// deferredModels maps the MorphType of every plugin model to the model, so
// the purge can delete records created under deferral. Two different model
// types with the same morph type are an error.
func deferredModels(gdb *gorm.DB, plugins []party.Plugin) (map[string]any, error) {
out := map[string]any{}
for _, p := range plugins {
hm, ok := p.(pact.HasModels)
if !ok {
continue
}
for _, m := range hm.Models() {
if m == nil {
continue
}
key, err := MorphType(gdb, m)
if err != nil {
return nil, fmt.Errorf("lagoon: deferred:purge: plugin %s: %w", p.ID(), err)
}
if prev, dup := out[key]; dup {
if modelType(prev) != modelType(m) {
return nil, fmt.Errorf("lagoon: deferred:purge: models %s and %s share the morph type %q", modelType(prev), modelType(m), key)
}
continue
}
out[key] = m
}
}
return out, nil
}
func modelType(m any) reflect.Type {
t := reflect.TypeOf(m)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t
}
// purgeBucket is the published uploads bucket, else one opened from
// storage.uploads.bucket_url and closed by the returned func. Without a
// configured bucket it returns nil; PurgeDeferred then refuses only when an
// expired binding points at a file.
func purgeBucket(ctx context.Context, app *backpack.App) (*blob.Bucket, func(), error) {
noop := func() {}
if app == nil {
return nil, noop, nil
}
if b, ok := app.Lookup[*blob.Bucket](); ok && b != nil {
return b, noop, nil
}
if app.Config == nil || strings.TrimSpace(app.Config.String("storage.uploads.bucket_url")) == "" {
return nil, noop, nil
}
b, err := attach.OpenBucket(ctx, app.Config)
if err != nil {
return nil, noop, err
}
return b, func() { _ = b.Close() }, nil
}
func withDB(ctx context.Context, app *backpack.App, fn func(*gorm.DB) error) error {
sqlDB, gdb, err := OpenFromApp(ctx, app)
if err != nil {