feat(32-02): record events and content descriptors (tracer)

- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit
- pact.ContentDescriptor and request/result types; MenuItemType.Content
- AdminFormSave.Changes carries the owner's changes to extension Save
- cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and
  translated values and fires RecordSaved through lagoon.AfterCommit
- signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
This commit is contained in:
2026-10-26 22:22:25 +01:00
parent 1b0421de81
commit 753fb5969a
12 changed files with 612 additions and 7 deletions

View File

@@ -35,7 +35,8 @@ Use cases:
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), 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`).
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot.
- Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`).
- Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the content locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events.
- Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list.
- 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.
@@ -149,7 +150,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions or form fields to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block (record actions) or a form (form fields), or a duplicate name stops the boot. |
| `pact.AdminFormExtension` | Fields and tabs a plugin places in another controller's form, loaded and saved by the plugin itself: a fields document in the `fields.yaml` format (`Fields`, a path in the plugin's `AdminFS`, or `FieldsYAML`, the bytes built at boot) whose fields take `before:`/`after:` anchors and `zone:` and whose `tabs:` list places new tabs; `Hide` (owner fields to leave out), `Permissions` and `Visible` (who sees the fields), `Dir`, `PartialData` and `Actions` (its partial and widget fields), `Load` (values on show) and `Save` (values in the record's save transaction). The owner's model is never written. |
| `pact.AdminFormLoad` | What `Load` receives: the read or save transaction `DB`, the target `Controller` ID, the loaded `Record` and its `ID` as text. |
| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create) and `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text). |
| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create), `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text), and `Changes`, what the owner's save changed (as `pact.RecordSaved` will report it). |
| `pact.AdminFormSaveResult` | What `Save` returns: `Notices`, save notices per field name (`pact.FormFieldNotice`), shown like a field type's notices. |
| `pact.AdminFormErrors` | A `Save` refusal: field name to messages (phrase keys or text). The save answers 422 with them on the fields and rolls back. |
| `pact.AdminFormZoneTabs` | The `zone:` value of the form's tab zone, the default and the only zone rendered so far; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` are reserved and stop the boot ("is not supported yet"). |
@@ -202,11 +203,22 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.DependentOptionsProvider` | Optional controller hook serving the choices of a dropdown with `dependsOn` from the submitted values of its siblings; asked by the options route and on every save. |
| `pact.DependentOptionSet` | `DependentOptions`' answer: `Applies` (false when the sibling's value takes no choice; the field then stores null) and `Options`. |
| `pact.HasMenuItemTypes` | Implemented by plugins that register menu item types; collected once at boot by signpost. |
| `pact.MenuItemType` | One menu item type: `Key`, `Label`, `DefaultPattern` (required, starts with `/`), `PatternFrom` (a list type's record type), `Placeholders`, `Nesting`, `DynamicItems`, the optional `References` func and the required `Resolve` func. |
| `pact.MenuItemType` | One menu item type: `Key`, `Label`, `DefaultPattern` (required, starts with `/`), `PatternFrom` (a list type's record type), `Placeholders`, `Nesting`, `DynamicItems`, the optional `References` func, the required `Resolve` func and the optional `Content` descriptor. |
| `pact.MenuItemReference` | One record a menu item can point at: value, label and nested records. |
| `pact.MenuItemResolve` | What `Resolve` receives: type, reference, the layered URL pattern, whether to expand nested items and the unprefixed locale. |
| `pact.ResolvedMenuItem` | A resolved item: title, URL (empty for a pure list type), modification time, ordered alternates and nested items. |
| `pact.MenuItemAlternate` | The URL and title of a resolved item in one locale. |
| `pact.ContentDescriptor` | The records behind a menu item type: `Controller` (the admin controller ID that edits them), `Model` (their morph type, required), `Describe` (required), and the optional `Records` and `Match`. The funcs read through `cabana.TxFromContext` when the context carries a write transaction. |
| `pact.ContentList` | What `Records` receives: the layered pattern, `After` (the last ID of the previous page), `Limit` and the unprefixed locale. |
| `pact.ContentPage` | One page of `Records`, and `Next`, the `After` of the following page ("" on the last). |
| `pact.ContentDescribe` | What `Describe` receives: the record `ID`, the layered pattern and the unprefixed locale. |
| `pact.ContentRecord` | A described record: `ID`, `Published`, `LastMod` and its `Locales`. |
| `pact.ContentLocale` | A record in one locale: `URL` ("" without a public page), `Title`, `Description`, `Image` and `Words`. |
| `pact.ContentMatch` | What `Match` receives: a public `Path`, the layered pattern and the unprefixed locale. |
| `pact.ContentMatchResult` | `Match`'s answer: the record `ID`, the `Locale` the path addresses and `Exact` (false when a segment does not match the record). |
| `pact.RecordSaved` | Event fired as a pointer after a record create or update commits: `Controller` ("" outside the admin forms), `Model` (morph type), `ID`, `Locale`, `Created` and `Changes`. |
| `pact.RecordDeleted` | Event fired as a pointer after a record delete commits, one per record: `Controller`, `Model`, `ID`. |
| `pact.RecordChange` | One changed attribute: `Attribute`, `Locale` ("" for the record's own row, the locale of a translated value), `Old` (nil on create) and `New`. |
## Dependencies

View File

@@ -546,7 +546,10 @@ type AdminFormLoad struct {
// the JSON body (numbers as json.Number), a translatable field's a
// map[string]string of locale to text, a plugin field type's the value its
// Decode returned. Save runs on every create and update of a record whose
// administrator sees the extension, with or without values.
// administrator sees the extension, with or without values. Changes lists
// what the owner's save changed on the record, as RecordSaved.Changes will
// (a translated slug once per changed locale), so Save sees a slug change
// inside the save transaction.
type AdminFormSave struct {
DB *gorm.DB
Controller string
@@ -554,6 +557,7 @@ type AdminFormSave struct {
ID string
Created bool
Values map[string]any
Changes []RecordChange
}
// AdminFormSaveResult is Save's answer. Notices are non-blocking messages per
@@ -944,7 +948,9 @@ type DependentOptionsProvider interface {
// item generates its children from records. References, when set, returns the
// records an item of the type can point at as a tree; nil means the type takes
// no reference. Resolve is required and turns one item into its URL, title,
// alternates and children.
// alternates and children. Content, when set, describes the records behind a
// record type (ContentDescriptor) for consumers such as sitemaps; nil means
// the type takes no part in them.
type MenuItemType struct {
Key string
Label string
@@ -955,6 +961,7 @@ type MenuItemType struct {
DynamicItems bool
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"`
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
Content *ContentDescriptor `json:"-"`
}
// MenuItemReference is one record a menu item can point at: the stored

100
modules/pact/content.go Normal file
View File

@@ -0,0 +1,100 @@
package pact
import (
"context"
"time"
)
// ContentDescriptor describes the records behind a menu item type, for
// consumers that work on every content type at once: sitemaps, search
// indexers, audit tools, metadata editors. A type sets it in
// MenuItemType.Content; a type without one takes no part and behaves as
// before.
//
// Controller is the ID of the admin controller whose form edits the
// records, "" when none does. Model is the records' morph type, the value
// RecordSaved and RecordDeleted carry (lagoon.MorphType), so an event maps to
// its content type with one lookup; it is required. Describe is required and
// describes one record by id. Records pages through the records and Match
// maps a public path to a record; both are optional.
//
// The functions read through cabana.TxFromContext(ctx) when ctx carries a
// write transaction, so code running inside a save (a form extension's Save,
// for example) sees the values the save has just written.
type ContentDescriptor struct {
Controller string
Model string
Records func(ctx context.Context, in ContentList) (ContentPage, error) `json:"-"`
Describe func(ctx context.Context, in ContentDescribe) (*ContentRecord, error) `json:"-"`
Match func(ctx context.Context, in ContentMatch) (*ContentMatchResult, error) `json:"-"`
}
// ContentList is what ContentDescriptor.Records receives. Pattern is the URL
// pattern already layered by the registry (the host's override, else the
// type's default). After is the ID of the last record of the previous page,
// "" for the first page; Limit is the page size the caller wants (the type
// may cap it). UnprefixedLocale names the locale whose URLs carry no locale
// prefix; "" leaves the choice to the type.
type ContentList struct {
Pattern string
After string
Limit int
UnprefixedLocale string
}
// ContentPage is one page of records in a stable order. Next is the After of
// the following page, "" on the last page.
type ContentPage struct {
Records []ContentRecord
Next string
}
// ContentDescribe is what ContentDescriptor.Describe receives: the record's
// ID, the layered URL pattern and the unprefixed locale, as in ContentList.
type ContentDescribe struct {
ID string
Pattern string
UnprefixedLocale string
}
// ContentRecord describes one record. ID is its primary key as text,
// Published whether visitors can see it, LastMod its last change (nil when
// unknown). Locales lists the record per locale in the site's locale order.
type ContentRecord struct {
ID string
Published bool
LastMod *time.Time
Locales []ContentLocale
}
// ContentLocale is a record in one locale. URL is its public path, "" when
// the locale or the host has no public page for it. Title, Description and
// Image (a URL) are the record's own defaults for page metadata; Words is the
// body's word count, nil when the type has no body.
type ContentLocale struct {
Locale string
URL string
Title string
Description string
Image string
Words *int
}
// ContentMatch is what ContentDescriptor.Match receives: a public path (no
// query, no host), the layered URL pattern and the unprefixed locale.
type ContentMatch struct {
Path string
Pattern string
UnprefixedLocale string
}
// ContentMatchResult names the record a path belongs to and the locale the
// path addresses. Exact is false when the path names the record but a
// segment does not match it (another category, a stale locale prefix, a
// slug of another locale): the caller may then redirect to the record's URL.
// Match answers nil when the path names no record visitors can see.
type ContentMatchResult struct {
ID string
Locale string
Exact bool
}

View File

@@ -0,0 +1,54 @@
package pact
// RecordChange is one changed attribute of a saved record. Attribute is the
// column or form field name. Locale is "" for a value stored on the record's
// own row, and the locale code for a translated value stored elsewhere (a
// translatable field's non-default locales); a translated slug therefore
// appears once per changed locale. Old is nil on create and for a
// translation that did not exist before; New is the value after the save.
// Values are as the model holds them (string, number, bool, *time.Time and
// so on), not formatted.
type RecordChange struct {
Attribute string
Locale string
Old any
New any
}
// RecordSaved is fired through the application's event bus (app.Events)
// after a record is created or updated, once the write has committed: a
// rolled-back save fires nothing. It is fired and listened to as a pointer:
//
// app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error { ... })
//
// The admin framework fires it for every admin write; plugin code that
// writes records directly fires it through cabana.EmitRecordSaved. A
// listener's error is logged and never fails the write that caused it.
//
// Controller is the ID of the admin controller that saved the record, ""
// for a write outside the admin forms. Model is the record's morph type
// (lagoon.MorphType: the model's MorphName, else its table name), the same
// value a content type declares in ContentDescriptor.Model. ID is the
// primary key as text. Locale is the request's content locale, "" when it
// has none. Created is true on create. Changes lists the attributes the save
// changed with their old and new values; on create it lists every written
// column with a nil Old. A save that changed nothing still fires, with an
// empty Changes, so subscribers may re-check what they derive from the
// record.
type RecordSaved struct {
Controller string
Model string
ID string
Locale string
Created bool
Changes []RecordChange
}
// RecordDeleted is fired like RecordSaved after a record is deleted and the
// delete has committed, once per record (a bulk delete fires one event per
// row). Controller, Model and ID follow RecordSaved.
type RecordDeleted struct {
Controller string
Model string
ID string
}