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

@@ -25,6 +25,7 @@ Use cases:
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and both plugin IDs), a nil `Resolve`, a `DefaultPattern` that is empty or does not start with `/`, a `PatternFrom` that names an unknown type, the type itself or a type that sets `PatternFrom` too, and a host override for an unknown type or one that does not start with `/`.
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden. An item override is taken only when it is a path (`signpost.ValidItemPattern`): it starts with a single `/` and has no surrounding whitespace or control characters. Any other override, such as a `javascript:` URL or a protocol-relative `//host`, is ignored and the next layer applies.
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentTypeForModel` maps the morph type a record event carries (`pact.RecordSaved.Model`) to its type, and `signpost.Registry.Describe` describes one record with the layered pattern. A descriptor without `Model` or `Describe` fails the boot.
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
## Usage
@@ -85,6 +86,55 @@ func (c entriesController) DependentOptions(ctx context.Context, field string, v
}
```
### Content descriptors
A record type adds a `pact.ContentDescriptor`: the admin controller that edits the records, their morph type, and a `Describe` that answers each locale's URL (built from the pattern it receives, as `Resolve` builds alternates), title, description, image and word count:
```go
{
Key: "acme-post",
DefaultPattern: "/blog/:slug",
Resolve: resolvePost,
Content: &pact.ContentDescriptor{
Controller: "acme.blog.posts",
Model: "acme_blog_posts",
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
post, err := loadPost(ctx, in.ID)
if err != nil || post == nil {
return nil, err
}
return &pact.ContentRecord{
ID: in.ID,
Published: post.Published,
Locales: []pact.ContentLocale{{
Locale: "en",
URL: strings.ReplaceAll(in.Pattern, ":slug", post.Slug),
Title: post.Title,
}},
}, nil
},
},
}
```
A consumer maps a record event to its type and reads the record's new URL:
```go
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
reg, _ := app.Lookup[*signpost.Registry]()
typ, ok := reg.ContentTypeForModel(ev.Model)
if !ok {
return nil
}
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
if err != nil || rec == nil {
return err
}
log.Printf("%s now lives at %s", ev.ID, rec.Locales[0].URL)
return nil
})
```
## API reference
| Identifier | Description |
@@ -96,6 +146,9 @@ func (c entriesController) DependentOptions(ctx context.Context, field string, v
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
| `signpost.ValidItemPattern` | Whether a pattern may serve as an item override: a path starting with a single `/`, without surrounding whitespace or control characters. Plugins use it to refuse such a pattern on save. |
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
| `signpost.Registry.ContentTypes` | The types that carry a content descriptor, in registration order. |
| `signpost.Registry.ContentTypeForModel` | The content type whose descriptor declares a morph type, as record events carry it. |
| `signpost.Registry.Describe` | Describes one record of a content type through its descriptor with the layered pattern; an unknown type or one without a descriptor is an error, an unknown record answers nil. |
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |

View File

@@ -0,0 +1,85 @@
package signpost
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
// checkContent validates a type's content descriptor: Model and Describe are
// required when Content is set.
func checkContent(owner string, t pact.MenuItemType) error {
c := t.Content
if c == nil {
return nil
}
if strings.TrimSpace(c.Model) == "" {
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Model", t.Key, owner)
}
if c.Describe == nil {
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Describe", t.Key, owner)
}
return nil
}
// ContentTypes returns the types that carry a content descriptor
// (pact.MenuItemType.Content), in registration order. The slice is a copy.
func (r *Registry) ContentTypes() []pact.MenuItemType {
out := []pact.MenuItemType{}
if r == nil {
return out
}
for _, t := range r.types {
if t.Content != nil {
out = append(out, t)
}
}
return out
}
// ContentTypeForModel returns the content type whose descriptor declares
// model, the morph type record events carry (pact.RecordSaved.Model).
func (r *Registry) ContentTypeForModel(model string) (pact.MenuItemType, bool) {
if r == nil || model == "" {
return pact.MenuItemType{}, false
}
for _, t := range r.types {
if t.Content != nil && t.Content.Model == model {
return t, true
}
}
return pact.MenuItemType{}, false
}
// Describe describes one record of the content type key through its
// descriptor, handing it the layered pattern (Pattern(key, "")). An unknown
// key or a type without a content descriptor is an error; a record the type
// does not know answers nil.
func (r *Registry) Describe(ctx context.Context, key, id, unprefixedLocale string) (*pact.ContentRecord, error) {
t, err := r.contentType(key)
if err != nil {
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
return t.Content.Describe(ctx, pact.ContentDescribe{
ID: id,
Pattern: r.Pattern(key, ""),
UnprefixedLocale: unprefixedLocale,
})
}
// contentType is the type key with its content descriptor.
func (r *Registry) contentType(key string) (pact.MenuItemType, error) {
t, ok := r.Type(key)
if !ok {
return pact.MenuItemType{}, fmt.Errorf("signpost: unknown menu item type %q", key)
}
if t.Content == nil {
return pact.MenuItemType{}, fmt.Errorf("signpost: menu item type %q has no content descriptor", key)
}
return t, nil
}

View File

@@ -78,6 +78,9 @@ func (r *Registry) add(owner string, t pact.MenuItemType) error {
if !strings.HasPrefix(t.DefaultPattern, "/") {
return fmt.Errorf("signpost: menu item type %q of plugin %s needs a DefaultPattern starting with \"/\", got %q", t.Key, owner, t.DefaultPattern)
}
if err := checkContent(owner, t); err != nil {
return err
}
t.Placeholders = append([]string(nil), t.Placeholders...)
r.owners[t.Key] = owner
r.byKey[t.Key] = len(r.types)