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:
@@ -23,6 +23,7 @@ The scheduler is the Go form of WinterCMS `registerSchedule`. Plugins declare re
|
||||
- Outcome rules in the worker: an error on an attempt before the last leaves the row in progress so River can retry; the final failed attempt, or a recovered panic on it, sets `conga.StatusError` with the error text under the metadata key `error`; an error on a row that was stopped or cancelled cancels the River job instead. A job that returns nil without completing its row leaves it as it is. Skipped work is recorded as complete with `{"skipped": true}` metadata.
|
||||
- Workers: `conga.StartWorker` registers every plugin job and starts one River client; `conga.WorkerOptions.Queues` limits it to some queues, and an unknown queue is `conga.ErrUnknownQueue` listing the known ones. `conga.Worker.Stop` stops it gracefully and cancels running jobs when its context ends. `conga.StartServeWorker` is the variant the `serve` command uses: it starts nothing when `queue.work_in_serve` is false. An app without jobs still gets a worker that starts and idles.
|
||||
- Scheduled commands: every worker carries one River periodic job per `pact.HasSchedule` entry, in plugin activation order then declaration order, with the id `<plugin id>[<index>]:<command>`. Only the elected leader enqueues. Each run is a `conga.ScheduledCommandArgs` job on `conga.QueueScheduled` with one attempt (an interrupted run is not retried; the next period runs normally) and unique by args within its cadence period, so a leader failover cannot double-enqueue a period. The worker runs a job only when its entry exists in the compiled schedule and its command and arguments match that entry exactly, so a forged `river_job` row cannot run an arbitrary command. An unregistered command, or an app with no published `bonfire.Catalog`, is logged at Warn (`schedule: command not registered; skipping`) and skipped without failing the worker or other entries. Command output is logged line by line at Info with a `command` attribute; failures are logged with the duration.
|
||||
- Framework schedule entries: before the plugin entries, every worker carries the framework's own entries from `lagoon.FrameworkSchedule`, listed under the plugin id `lagoon.FrameworkScheduleID`. Today that is the daily `deferred:purge` at `database.deferred_bindings.purge_at` (default `03:00`), with the id `summercms.lagoon[0]:deferred:purge`; it runs, appears in `schedule:run --once` and is protected by the compiled entry table exactly like a plugin entry. An empty `purge_at` removes it, and a malformed one fails the worker start.
|
||||
- Wall-clock schedules: `conga.Daily` fires at the next `hh:mm` in its location and `conga.Every` at the next multiple of its interval since local midnight, so a restart never delays a daily run by up to a day the way `river.PeriodicInterval(24h)` would. On a DST day `conga.Daily` keeps the wall-clock time. A schedule entry with an empty command, a zero cadence, a daily time out of range, or an interval under one second or not dividing 24h fails the worker start with an error naming the plugin id and entry index.
|
||||
- Commands: `conga.RuntimeCommands` adds `queue:work`, `schedule:run` and `queue:clear` to the application binary. `schedule:run` runs a scheduler-only worker in its own process, and `schedule:run --once` runs the entries due in the current minute without River, for system cron.
|
||||
|
||||
@@ -172,6 +173,7 @@ Keys are read from the compass config (`config/queue.yaml`, or `SUMMER_QUEUE__..
|
||||
| `queue.job_timeout` | `300` | Per-attempt deadline, in seconds or as a duration string such as `5m`, unless the job sets `conga.Timeout`. |
|
||||
| `queue.queues.<name>` | `default: 4`, `scheduled: 1` | Concurrent workers per queue. The worker runs these queues plus every queue a registered job names plus `default` and `scheduled`. |
|
||||
| `app.timezone` | `UTC` | IANA location of `pact.Daily`, `pact.DailyAt` and `pact.Every` schedules. An unknown name fails the worker start. |
|
||||
| `database.deferred_bindings.purge_at` | `03:00` | Daily time of the framework's `deferred:purge` entry (read by `lagoon.FrameworkSchedule`); an empty value disables the entry. |
|
||||
| `database.dsn` | none (required) | Also opens the worker's single-connection `LISTEN` pool. With PgBouncer, that connection must use session pooling or go straight to Postgres; transaction pooling cannot hold a `LISTEN`. |
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -790,3 +790,56 @@ func TestScheduleDueAt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFrameworkScheduleSmoke covers the framework's own daily purge entry:
|
||||
// first in the entry list when the app has config, removed by an empty
|
||||
// purge_at, absent for a config-less app, and a boot error when malformed.
|
||||
func TestFrameworkScheduleSmoke(t *testing.T) {
|
||||
plugins := schedulePlugins(pact.ScheduledCommand{Command: "acme:tick", Cadence: pact.Daily()})
|
||||
withConfig := func(t *testing.T, extra map[string]any) *backpack.App {
|
||||
t.Helper()
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range extra {
|
||||
if err := cfg.Set(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
entries, err := scheduleEntries(withConfig(t, nil), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].id != "summercms.lagoon[0]:deferred:purge" || entries[0].plugin != "summercms.lagoon" {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
if entries[0].cmd.Cadence != pact.DailyAt(3, 0) || entries[1].id != "acme.test[0]:acme:tick" {
|
||||
t.Fatalf("framework entry %+v, next %s", entries[0].cmd, entries[1].id)
|
||||
}
|
||||
_, table, err := periodicJobs(withConfig(t, map[string]any{"database.deferred_bindings.purge_at": "04:30"}), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fw, ok := table["summercms.lagoon[0]:deferred:purge"]; !ok || fw.Cadence != pact.DailyAt(4, 30) {
|
||||
t.Fatalf("compiled table = %+v", table)
|
||||
}
|
||||
|
||||
entries, err = scheduleEntries(withConfig(t, map[string]any{"database.deferred_bindings.purge_at": ""}), plugins)
|
||||
if err != nil || len(entries) != 1 || entries[0].id != "acme.test[0]:acme:tick" {
|
||||
t.Fatalf("disabled: %+v (%v)", entries, err)
|
||||
}
|
||||
|
||||
entries, err = scheduleEntries(backpack.New(nil), plugins)
|
||||
if err != nil || len(entries) != 1 || entries[0].plugin == "summercms.lagoon" {
|
||||
t.Fatalf("nil config: %+v (%v)", entries, err)
|
||||
}
|
||||
|
||||
_, err = scheduleEntries(withConfig(t, map[string]any{"database.deferred_bindings.purge_at": "3am"}), plugins)
|
||||
if err == nil || !strings.Contains(err.Error(), "database.deferred_bindings.purge_at") {
|
||||
t.Fatalf("malformed purge_at = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/riverqueue/river"
|
||||
@@ -60,15 +61,35 @@ func (e scheduleEntry) construct() (river.JobArgs, *river.InsertOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleEntries compiles the pact.HasSchedule entries of plugins in
|
||||
// activation order, then declaration order. Entry ids are
|
||||
// "<plugin id>[<index>]:<command>".
|
||||
// scheduleEntries compiles the framework's own entries
|
||||
// (lagoon.FrameworkSchedule, listed under lagoon.FrameworkScheduleID) and
|
||||
// then the pact.HasSchedule entries of plugins in activation order, then
|
||||
// declaration order. Entry ids are "<plugin id>[<index>]:<command>".
|
||||
func scheduleEntries(app *backpack.App, plugins []party.Plugin) ([]scheduleEntry, error) {
|
||||
loc, err := appLocation(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []scheduleEntry
|
||||
framework, err := lagoon.FrameworkSchedule(app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conga: framework schedule: %w", err)
|
||||
}
|
||||
for i, sc := range framework {
|
||||
sched, period, err := scheduleFor(sc.Cadence, loc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("conga: framework schedule entry %d (%s): %w", i, sc.Command, err)
|
||||
}
|
||||
sc.Args = slices.Clone(sc.Args)
|
||||
out = append(out, scheduleEntry{
|
||||
id: fmt.Sprintf("%s[%d]:%s", lagoon.FrameworkScheduleID, i, sc.Command),
|
||||
plugin: lagoon.FrameworkScheduleID,
|
||||
index: i,
|
||||
cmd: sc,
|
||||
schedule: sched,
|
||||
period: period,
|
||||
})
|
||||
}
|
||||
for _, p := range plugins {
|
||||
hs, ok := p.(pact.HasSchedule)
|
||||
if !ok {
|
||||
|
||||
@@ -144,7 +144,9 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.PurgeDeferred` | Removes expired bindings, their unattached files (blobs after commit) and the children created under deferral. |
|
||||
| `lagoon.PurgeOptions` | Cut-off time and created-child model resolver for `lagoon.PurgeDeferred`. |
|
||||
| `lagoon.PurgeResult` | Counts of deleted bindings, files and children and of skipped bindings. |
|
||||
| `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status and key:generate commands. |
|
||||
| `lagoon.RuntimeCommands` | Returns the migrate, migrate:rollback, migrate:status, deferred:purge and key:generate commands. |
|
||||
| `lagoon.FrameworkSchedule` | The framework's scheduled commands for conga: the daily `deferred:purge` at `database.deferred_bindings.purge_at`, or none when it is empty. |
|
||||
| `lagoon.FrameworkScheduleID` | Plugin id the framework's schedule entries are listed under, `summercms.lagoon`. |
|
||||
| `lagoon.KeyGenerateCommand` | Returns the key:generate command on its own. |
|
||||
| `lagoon.LoadAppKey` | Decodes `app.key` and `app.previous_keys`. |
|
||||
| `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. |
|
||||
@@ -214,6 +216,8 @@ Keys are read from the compass config; the `SUMMER_` environment overlay maps a
|
||||
| `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. |
|
||||
| `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://` or `mem://`. |
|
||||
| `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. |
|
||||
| `database.deferred_bindings.purge_days` | `5` | Age in days after which `deferred:purge` removes a deferred binding, WinterCMS's `cleanUp(5)`; the `--days` flag overrides it. |
|
||||
| `database.deferred_bindings.purge_at` | `03:00` | Daily `HH:MM` (in `app.timezone`) at which the scheduler runs `deferred:purge`; an empty value disables the framework entry. |
|
||||
|
||||
```yaml
|
||||
database:
|
||||
@@ -234,6 +238,7 @@ storage:
|
||||
| `migrate` | none | Runs the framework migrations (`system_files`, the backend admin tables, `deferred_bindings` and the job queue), then each plugin's migrations in dependency order. |
|
||||
| `migrate:rollback` | `--plugin <id>` | Rolls back the last migration of the given plugin; without the flag, of the last activated plugin that has migrations. |
|
||||
| `migrate:status` | none | Prints a table of plugin, history table and applied migration IDs. |
|
||||
| `deferred:purge` | `--days <n>` | Deletes deferred bindings older than `n` days (default `database.deferred_bindings.purge_days`, else 5), with the unattached uploads they hold (blobs after the commit) and the records created under deferral, and prints the counts. Records that were only linked are kept. Model types come from every plugin's `pact.HasModels`; the uploads bucket from the published one or `storage.uploads.bucket_url`. |
|
||||
| `key:generate` | none | Prints a fresh base64 32-byte key for `app.key`; writes nothing. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) {
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
}
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "key:generate"} {
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "deferred:purge", "key:generate"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
|
||||
58
modules/lagoon/schedule.go
Normal file
58
modules/lagoon/schedule.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// FrameworkScheduleID is the plugin id under which the scheduler lists the
|
||||
// framework's own schedule entries, so the daily purge has the entry id
|
||||
// summercms.lagoon[0]:deferred:purge.
|
||||
const FrameworkScheduleID = "summercms.lagoon"
|
||||
|
||||
const (
|
||||
purgeAtKey = "database.deferred_bindings.purge_at"
|
||||
purgeDaysKey = "database.deferred_bindings.purge_days"
|
||||
defaultPurgeAt = "03:00"
|
||||
// defaultPurgeDays is WinterCMS's DeferredBinding::cleanUp(5).
|
||||
defaultPurgeDays = 5
|
||||
)
|
||||
|
||||
var purgeAtPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):([0-5][0-9])$`)
|
||||
|
||||
// FrameworkSchedule returns the scheduled commands the framework itself
|
||||
// runs: deferred:purge daily at database.deferred_bindings.purge_at ("HH:MM"
|
||||
// in app.timezone, default "03:00"). An explicitly empty purge_at disables
|
||||
// the entry; a malformed one is an error naming the key. A nil app or an app
|
||||
// without config returns no entries and no error.
|
||||
func FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error) {
|
||||
if app == nil || app.Config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
at := defaultPurgeAt
|
||||
if raw, ok := app.Config.Lookup(purgeAtKey); ok {
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
at = ""
|
||||
case string:
|
||||
at = strings.TrimSpace(v)
|
||||
default:
|
||||
return nil, fmt.Errorf("lagoon: %s must be a quoted \"HH:MM\" string, got %v", purgeAtKey, raw)
|
||||
}
|
||||
}
|
||||
if at == "" {
|
||||
return nil, nil
|
||||
}
|
||||
m := purgeAtPattern.FindStringSubmatch(at)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("lagoon: %s %q is not HH:MM (00:00 to 23:59)", purgeAtKey, at)
|
||||
}
|
||||
hour, _ := strconv.Atoi(m[1])
|
||||
minute, _ := strconv.Atoi(m[2])
|
||||
return []pact.ScheduledCommand{{Command: "deferred:purge", Cadence: pact.DailyAt(hour, minute)}}, nil
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
|
||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
||||
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
|
||||
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
|
||||
@@ -111,6 +111,13 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
|
||||
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
|
||||
| `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. |
|
||||
| `pact.RelationBeforeCreate` | Optional controller hook run in the write transaction before a relation manager creates a related record. |
|
||||
| `pact.RelationAfterCreate` | Optional controller hook run after a relation manager creates a related record, before the commit. |
|
||||
| `pact.RelationBeforeUpdate` | Optional controller hook run in the write transaction before a relation manager updates a related record. |
|
||||
| `pact.RelationAfterUpdate` | Optional controller hook run after a relation manager updates a related record, before the commit. |
|
||||
| `pact.RelationBeforeDelete` | Optional controller hook run in the write transaction before a relation manager deletes a related record. |
|
||||
| `pact.RelationAfterDelete` | Optional controller hook run after a relation manager deletes a related record, before the commit. |
|
||||
| `pact.Option` | One dropdown choice (value and label). |
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -391,6 +391,51 @@ type RelationBeforeLink interface {
|
||||
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
|
||||
}
|
||||
|
||||
// The six relation child hooks below are optional admin-controller
|
||||
// capabilities for a relation manager that creates, updates or deletes
|
||||
// related records. Before hooks run inside the child write's transaction
|
||||
// before the row is written; After hooks run after the write and before the
|
||||
// commit. parent is the loaded parent record, or a fresh record with a zero
|
||||
// key while the parent is not saved yet (deferral). child is the related
|
||||
// record being written. An error rolls the write back and the admin API
|
||||
// answers its opaque lifecycle error.
|
||||
|
||||
// RelationBeforeCreate optionally runs before a related record is created
|
||||
// through a relation manager.
|
||||
type RelationBeforeCreate interface {
|
||||
RelationBeforeCreate(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// RelationAfterCreate optionally runs after a related record is created
|
||||
// through a relation manager, before the commit.
|
||||
type RelationAfterCreate interface {
|
||||
RelationAfterCreate(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// RelationBeforeUpdate optionally runs before a related record is updated
|
||||
// through a relation manager.
|
||||
type RelationBeforeUpdate interface {
|
||||
RelationBeforeUpdate(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// RelationAfterUpdate optionally runs after a related record is updated
|
||||
// through a relation manager, before the commit.
|
||||
type RelationAfterUpdate interface {
|
||||
RelationAfterUpdate(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// RelationBeforeDelete optionally runs before a related record is deleted
|
||||
// through a relation manager.
|
||||
type RelationBeforeDelete interface {
|
||||
RelationBeforeDelete(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// RelationAfterDelete optionally runs after a related record is deleted
|
||||
// through a relation manager, before the commit.
|
||||
type RelationAfterDelete interface {
|
||||
RelationAfterDelete(ctx context.Context, relation string, parent, child any) error
|
||||
}
|
||||
|
||||
// FilterScope is the model capability a config_filter scope name may call.
|
||||
// FilterScopes is the exact, case-sensitive set of names; request text never
|
||||
// selects a method outside that set.
|
||||
|
||||
Reference in New Issue
Block a user