feat(11-02): run plugin schedules as River periodic jobs through bonfire.Call

- pact.HasSchedule with ScheduledCommand and Daily/DailyAt/Every cadences (no River import)
- bonfire.Call, Catalog and ErrUnknownCommand for in-process command runs
- conga Daily/Every wall-clock schedules in app.timezone, periodic jobs on every worker,
  scheduled queue (MaxAttempts 1, unique by args within the cadence period)
- scheduled worker runs only entries matching the compiled table; unregistered
  commands are skipped with a Warn log
- generated app main publishes bonfire.NewCatalog(commands); hello main regenerated
This commit is contained in:
Jakub Zych
2026-09-29 19:47:51 +02:00
parent 77b8ff177a
commit d9f939a1ea
15 changed files with 890 additions and 12 deletions

View File

@@ -1,24 +1,25 @@
# pact
Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs.
Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates, jobs and scheduled commands.
`import "git.golem15.com/golem15/summercms/modules/pact"`
## Overview
`pact` is the contract layer between plugins and the framework. It holds interfaces and plain data types, with no behaviour of its own. A plugin opts into a capability by implementing one of the `Has*` interfaces, and the framework package that owns the capability discovers it with a type assertion ([party](../party/README.md) for config, [surf](../surf/README.md) for routes and middleware, [lagoon](../lagoon/README.md) for migrations, [cabana](../cabana/README.md) for admin controllers). It replaces the `register*()` methods of a WinterCMS PluginBase (`registerPermissions`, `registerNavigation`, `registerSettings` and so on) with small, separately implementable interfaces.
`pact` is the contract layer between plugins and the framework. It holds interfaces and plain data types, with no behaviour of its own. A plugin opts into a capability by implementing one of the `Has*` interfaces, and the framework package that owns the capability discovers it with a type assertion ([party](../party/README.md) for config, [surf](../surf/README.md) for routes and middleware, [lagoon](../lagoon/README.md) for migrations, [cabana](../cabana/README.md) for admin controllers, [conga](../conga/README.md) for jobs and schedules). It replaces the `register*()` methods of a WinterCMS PluginBase (`registerPermissions`, `registerNavigation`, `registerSettings` and so on) with small, separately implementable interfaces.
## Features
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- Plugin capability interfaces: `pact.HasRoutes`, `pact.HasConfig`, `pact.HasMigrations`, `pact.HasCommands`, `pact.HasModels`, `pact.HasJobs`, `pact.HasSchedule`, `pact.HasLang`, `pact.HasLangOverrides` and `pact.HasMailTemplates`.
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- 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`).
- 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.
- `pact.HasModels`, `pact.HasJobs` and `pact.OptionalMessage` are declared for plugins to implement, but no framework package consumes them yet.
- `pact.HasModels` and `pact.OptionalMessage` are declared for plugins to implement, but no framework package consumes them yet.
## Usage
@@ -59,6 +60,19 @@ func listPosts(w http.ResponseWriter, r *http.Request) {}
func showPost(w http.ResponseWriter, r *http.Request) {}
```
A plugin schedules one of its registered commands by implementing `pact.HasSchedule`:
```go
var _ pact.HasSchedule = (*Plugin)(nil)
func (p *Plugin) Schedule() []pact.ScheduledCommand {
return []pact.ScheduledCommand{
{Command: "blog:prune-drafts", Cadence: pact.Daily()},
{Command: "blog:ping", Args: []string{"--quiet"}, Cadence: pact.Every(15 * time.Minute)},
}
}
```
## API reference
| Identifier | Description |
@@ -75,6 +89,12 @@ func showPost(w http.ResponseWriter, r *http.Request) {}
| `pact.HasModels` | Exposes GORM models. |
| `pact.Job` | Background unit of work that receives `pact.JobArgs`. |
| `pact.HasJobs` | Registers background jobs. |
| `pact.HasSchedule` | Declares recurring console commands (`Schedule() []pact.ScheduledCommand`); conga workers run them. |
| `pact.ScheduledCommand` | One schedule entry: `Command`, `Args` and `Cadence`. |
| `pact.Cadence` | Opaque run frequency with `IsZero`, `Interval` (24h for daily cadences) and `At` (hour and minute of a daily cadence). |
| `pact.Daily` | Cadence at 00:00 every day in the app timezone (Laravel `->daily()`). |
| `pact.DailyAt` | Cadence at a given hour and minute every day in the app timezone. |
| `pact.Every` | Cadence at every multiple of an interval since local midnight; the interval must be at least 1s and divide 24h. |
| `pact.HasLang` | Ships translation YAML under `lang/<locale>/<group>.yaml`. |
| `pact.HasLangOverrides` | Replaces or adds translations of any loaded namespace, including the framework's own. |
| `pact.HasMailTemplates` | Ships mail templates and layout aliases. |
@@ -97,7 +117,7 @@ func showPost(w http.ResponseWriter, r *http.Request) {}
- SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`).
- Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`.
- Standard library: `context`, `io/fs`, `net/http`.
- Standard library: `context`, `io/fs`, `net/http`, `time`.
## Testing

View File

@@ -4,6 +4,7 @@ import (
"context"
"io/fs"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/go-gormigrate/gormigrate/v2"
@@ -98,6 +99,77 @@ type HasJobs interface {
Jobs() []Job
}
type cadenceKind uint8
const (
cadenceNone cadenceKind = iota
cadenceDaily
cadenceEvery
)
// Cadence is how often a scheduled command runs. Build one with Daily,
// DailyAt or Every; the zero value is no cadence and is rejected by the
// scheduler.
type Cadence struct {
kind cadenceKind
hour int
minute int
every time.Duration
}
// Daily runs once a day at 00:00 in the app timezone (Laravel ->daily()).
func Daily() Cadence { return DailyAt(0, 0) }
// DailyAt runs once a day at hour:minute in the app timezone.
func DailyAt(hour, minute int) Cadence {
return Cadence{kind: cadenceDaily, hour: hour, minute: minute}
}
// Every runs at every multiple of d since local midnight in the app
// timezone. The scheduler requires d to be at least one second and to divide
// 24h evenly.
func Every(d time.Duration) Cadence {
return Cadence{kind: cadenceEvery, every: d}
}
// IsZero reports whether c is the zero Cadence.
func (c Cadence) IsZero() bool { return c.kind == cadenceNone }
// Interval is the period of c: 24h for a daily cadence, d for Every(d) and
// zero for the zero Cadence.
func (c Cadence) Interval() time.Duration {
switch c.kind {
case cadenceDaily:
return 24 * time.Hour
case cadenceEvery:
return c.every
}
return 0
}
// At returns the wall-clock time of a daily cadence; ok is false for Every
// and for the zero Cadence.
func (c Cadence) At() (hour, minute int, ok bool) {
if c.kind != cadenceDaily {
return 0, 0, false
}
return c.hour, c.minute, true
}
// ScheduledCommand is one recurring run of a registered console command.
// Command is the command name (namespace:verb) and Args its arguments.
type ScheduledCommand struct {
Command string
Args []string
Cadence Cadence
}
// HasSchedule is implemented by plugins that run console commands on a
// schedule. Only these compiled entries are ever executed by the scheduler.
type HasSchedule interface {
Schedule() []ScheduledCommand
}
// AdminController is the compile-time admin controller contract. Phase 9
// grows the schema pipeline; ID, model name and YAML config directory are
// enough for generated stubs to compile.
@@ -369,10 +441,10 @@ type OptionalMessage interface {
// packages exist:
//
// HasListeners
// HasSchedule
//
// The kernel type-asserts HasConfig (party, before Register), HasCommands
// (generated app main, after Boot), HasMigrations (lagoon migrate), and
// (generated app main, after Boot), HasMigrations (lagoon migrate),
// HasJobs and HasSchedule (conga workers), and
// HasMiddleware/HasMiddlewareFactories/HasHouseMiddleware/HasRoutes
// (surf assemble). surf.BucketProvider is type-asserted in Assemble/
// BuildRouter (not a pact interface: pact cannot import surf without a cycle).