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:
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user