feat(32-02): record events from every admin write path

- Delete and each bulk-delete row fire RecordDeleted after commit
- tree reorder fires RecordSaved per moved record (parent/sort in model types)
- relation-manager child create/update/delete fire events naming the
  controller that edits the child's model
- TestRecordEvents covers create, update, translated slug, no-op, rollback
  (validation, FormBeforeUpdate, extension Save), failing listener, reorder,
  child writes, delete and bulk delete; extension Save sees the changes
- docs: Record events in extending.md, admin-controllers notes, cabana README
This commit is contained in:
2026-10-26 22:35:03 +01:00
parent 753fb5969a
commit ff62906301
11 changed files with 737 additions and 3 deletions

View File

@@ -222,6 +222,8 @@ A hook or scope that has to read the database during a write should use the tran
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
Other plugins do not implement these hooks to follow your records; they listen for the record events every admin write fires after its commit: `pact.RecordSaved` from create, update, tree reorder (one per moved record) and relation-manager child create and update, `pact.RecordDeleted` from delete, each row of a bulk delete and relation-manager child delete. Record and bulk actions write through your own code and fire nothing unless that code calls `cabana.EmitRecordSaved` or `cabana.EmitRecordDeleted`. See [Record events](../plugins/extending.md#record-events).
### Form-only values and rules
A form may collect values that are not columns of the record (see [Form-only fields](forms.md#form-only-fields)). The Form hooks read what the administrator submitted with `cabana.VirtualFieldsFromContext(ctx)`. The map holds only the fields that were sent and that the field's `context` allows for this operation, so a missing key means "not submitted". Values are scalars as decoded from the request: a string, a bool, a `json.Number` or nil. The map is a copy, and the second result is false outside a create or update.
@@ -606,7 +608,7 @@ Every field and tab of a compiled form has a stable name: the field key, and the
An extension adds value fields (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` with listed options, `markdown`, `mltext`, `mltextarea`, `mlmarkdown` and plugin field types), `widget` fields and `partial` fields. Types that bind to the owner's model are refused at boot, `fileupload` included: an extension owns no attachments, so a plugin field type with `Upload` stores files instead.
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, and `Values`, the submitted values of the extension's fields whose `context` allows the operation. `Save` runs on every create and update, with or without values.
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, `Values`, the submitted values of the extension's fields whose `context` allows the operation, and `Changes`, what the owner's save changed (old and new values, a translated value per locale), as the save's `pact.RecordSaved` will report it after the commit. `Save` runs on every create and update, with or without values.
- A translatable field (`mltext`, `mltextarea`, `mlmarkdown`, a translatable plugin field type) reaches `Save` as a `map[string]string` of locale to text, and `Load` returns it the same way. A plugin field type's `Decode` runs before `Save` (its notices are kept, a `pact.FormFieldError` is a 422 on the field) and its `Load` after the extension's `Load`.
- A `Save` error rolls the whole save back, the owner's row included. `pact.AdminFormErrors` (field name to messages, phrase keys or text) answers 422 with those lines on the fields; any other error is logged and answered with the generic 500 body. `pact.AdminFormSaveResult.Notices` are save notices per field, shown on the field like a field type's notices; a notice may name an owner field.
- On show and in the save response, `Load` returns the values by field name; keys that are not the extension's fields are dropped.

View File

@@ -44,6 +44,42 @@ Each listener names the plugin that owns it, so an error or a recovered panic in
`festival.Bus.ListenPriority` sets a priority: higher priorities run first, and equal priorities run in registration order.
### Record events
The framework fires two events for every record written through the admin: `pact.RecordSaved` after a create or update, `pact.RecordDeleted` after a delete. They are fired as pointers, so a listener takes `*pact.RecordSaved` or `*pact.RecordDeleted`.
A `pact.RecordSaved` carries the admin controller ID (`Controller`), the record's morph type (`Model`, the model's `MorphName`, else its table name), its primary key as text (`ID`), the request locale, `Created` and `Changes`: one `pact.RecordChange` per changed attribute with its old and new value. A translated value is reported once per locale, with `Locale` set, so a translated slug change is visible too. A save that changed nothing still fires, with no changes. A `pact.RecordDeleted` carries `Controller`, `Model` and `ID`.
The events fire after the write commits, never for a write that rolled back, and synchronously before the response is written. A listener's error is logged and never fails the write. Admin creates and updates, deletes and bulk deletes, tree reorders (a moved record reports its parent and sort columns) and relation-manager child writes all fire them; a child's event names the controller that edits the child's model. A form extension's `Save` receives the same changes in `pact.AdminFormSave.Changes`, inside the save transaction.
Code that writes records outside the admin (its own API handlers, imports, record actions) fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`. Pass the write's `lagoon.Transaction` handle and the event waits for its commit; pass nil after the write has committed and it fires at once:
```go src=modules/cabana/example_record_events_test.go#ExampleEmitRecordSaved
app := backpack.New(nil) // in a plugin, the application it booted with
// acme.audit listens from its Boot step; record events arrive as pointers.
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
for _, change := range ev.Changes {
fmt.Printf("%s %s: %s %q -> %q (locale %q)\n", ev.Model, ev.ID, change.Attribute, change.Old, change.New, change.Locale)
}
return nil
})
// acme.blog's own API handler has saved a post outside the admin forms.
// The write has committed, so it passes no transaction and the event
// fires at once; inside a lagoon.Transaction it would pass tx and the
// event would wait for the commit.
cabana.EmitRecordSaved(context.Background(), app, nil, pact.RecordSaved{
Model: "acme_blog_posts",
ID: "42",
Changes: []pact.RecordChange{{Attribute: "slug", Old: "hello", New: "hello-world"}},
})
// Output:
// acme_blog_posts 42: slug "hello" -> "hello-world" (locale "")
```
Prefer these events to a GORM callback on another plugin's model (see [Model hooks and GORM callbacks](#model-hooks-and-gorm-callbacks)): a callback sees one statement, not the admin save it belongs to, cannot tell old translated values from new ones, and runs before the transaction commits.
## Services
A plugin that offers functionality to others publishes it on the container during Register with `backpack.App.Publish`, preferably under an interface type. Other plugins read it during Boot with `backpack.App.Lookup`. Because both sides use the same type, the consumer only imports the package that declares the interface, not the provider's internals. See [Application lifecycle](../architecture/application-lifecycle.md) for a complete example.

View File

@@ -61,7 +61,7 @@ Use cases:
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Prompted record actions: a `pact.AdminRecordAction` with a `Prompt` (`pact.AdminActionPrompt`) asks the administrator for one text value in its confirm dialog. The record response serves it as `prompt` on the action's `cabana.RecordAction` (absent for every other action, so existing payloads are unchanged), and the action route accepts the body key `payload`, a JSON string, only for such an action: trimmed, not empty and at most `max_length` runes (422 on `payload` with `backend::lang.form.action_prompt_required` or `action_prompt_too_long`, `backend::lang.form.action_prompt_invalid` for another JSON type), then handed to `Run` as `pact.AdminRecordActionInput.Value`. Every other record action keeps refusing `record_id`, `values` and `payload`. A prompt without a label fails boot, owned or contributed.
- My account contributions: a plugin implementing `pact.HasAccountContributions` adds sections (`pact.AccountContribution`) to the administrator's own account page, each with a status line and buttons (`pact.AccountAction`). GET `/account/contributions` lists them for the signed-in administrator and POST `/account/contributions/{name}/actions/{action}` runs one for that administrator only, behind the interactive-only guard, the action's permissions and its `Applies` rule, inside a transaction; the answer may carry a URL the admin opens in a new tab. That URL is kept only when it is an absolute `http`/`https` URL with a host or a same-origin path (one leading `/`); anything else, `javascript:` and `data:` included, is dropped and logged without the URL. A section whose `Status` fails is listed with an empty status and `error: true`, and an action whose `Applies` fails is left out and marks its section `error: true` as well; both are logged and the other sections are answered as usual. A status is translated only when it is a phrase key. Names are generic: cabana never names the contributing plugin. The existing `/account*` and `/auth/me` payloads are unchanged. A non-identifier or duplicate section name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot.
- Record events: every admin save fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them). A rolled-back save fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
- Record events: every admin create and update fires `pact.RecordSaved` on the application's event bus after the commit, with the controller ID, the record's morph type, its id, the request locale, `Created` and the changed attributes with old and new values (column changes, then translated values per locale, read before the save writes them); a save that changed nothing fires with no changes. Delete and every row of a bulk delete fire `pact.RecordDeleted`; a tree reorder fires `pact.RecordSaved` for each moved record (parent and sort columns); relation-manager child writes fire both for the child, naming the controller that edits the child's model. A rolled-back write fires nothing and a listener's error is logged, never returned. The same changes reach a form extension's `Save` in `pact.AdminFormSave.Changes`. Plugin code that writes records directly fires the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`.
- Backend subject events: a save on the Administrators screen that changed an administrator's role, permission overrides or superuser flag, a Roles save that changed a role's grants (one event per administrator holding it), a deactivation or deletion (record and bulk forms) and a revoked personal API token fire `pact.MCPSubjectChanged` on the application's event bus with guard `backend`, so a module acting on behalf of an administrator elsewhere (the MCP endpoint) re-lists what they may do or ends their sessions. A revoked token's event carries its `TokenID`, so only the sessions opened with that token end; the administrator's other sessions stay open. Only the My account revoke fires it: `admin:token:revoke` runs in its own process, where no MCP endpoint listens, so that token's sessions end when the token is next verified (the session's next request or its stream's periodic bearer re-check). A listener's error is logged and never fails the save.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A `type: password` field on a settings form is a write-only secret on a `lagoon.Encrypted` column (any other column type fails boot naming the field): the GET omits it from `data` and reports `stored`, `env` or `empty` per field in `meta.secrets` (`cabana.SettingsMeta`; `env` when the column is empty and the model's `cabana.SecretFallbacks` config key resolves to a non-empty value); on PUT an absent key or an empty string keeps the stored value, JSON null clears it, a non-string is a 422 on the field, and the answer carries the new states, never a value. A `type: richeditor` on a settings form is sanitized on save exactly like a controller save, with the removals in `meta.notices`; `repeater` and `dependsOn` stay refused. Settings fields keep their `tab`. A `type: dropdown` whose `options` names a method takes its choices from the settings model, which must implement `pact.DropdownOptionsProvider` (boot fails naming the field otherwise); the settings schema serves them localized. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An entry whose `pact.NavigationItem.Visible` func answers false for the request is left out like a denied one (a hidden main item with its side menu), and a main item whose controller is a side item hidden this way links to its first visible openable side item. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.

View File

@@ -325,6 +325,7 @@ func (s CRUDService) Delete(ctx context.Context, cc *CompiledController, id any)
if err := deleteRecord(ctx, tx, cc, target); err != nil {
return err
}
emitRecordDeleted(ctx, s.app, tx, cc, target)
result.Deleted = 1
return nil
})
@@ -379,6 +380,7 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
if err := deleteRecord(ctx, tx, cc, row); err != nil {
return err
}
emitRecordDeleted(ctx, s.app, tx, cc, row)
}
result.Deleted = len(rows)
return nil

View File

@@ -0,0 +1,34 @@
package cabana_test
import (
"context"
"fmt"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
func ExampleEmitRecordSaved() {
app := backpack.New(nil) // in a plugin, the application it booted with
// acme.audit listens from its Boot step; record events arrive as pointers.
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
for _, change := range ev.Changes {
fmt.Printf("%s %s: %s %q -> %q (locale %q)\n", ev.Model, ev.ID, change.Attribute, change.Old, change.New, change.Locale)
}
return nil
})
// acme.blog's own API handler has saved a post outside the admin forms.
// The write has committed, so it passes no transaction and the event
// fires at once; inside a lagoon.Transaction it would pass tx and the
// event would wait for the commit.
cabana.EmitRecordSaved(context.Background(), app, nil, pact.RecordSaved{
Model: "acme_blog_posts",
ID: "42",
Changes: []pact.RecordChange{{Attribute: "slug", Old: "hello", New: "hello-world"}},
})
// Output:
// acme_blog_posts 42: slug "hello" -> "hello-world" (locale "")
}

View File

@@ -775,7 +775,7 @@ func (s *service) relations() (RelationService, error) {
if err != nil {
return RelationService{}, err
}
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}
svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app, reg: s.reg}
if s.app != nil {
if w, ok := s.app.Lookup[TranslationWriter](); ok {
svc.writer = w

View File

@@ -3,6 +3,7 @@ package cabana
import (
"context"
"reflect"
"strconv"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -85,6 +86,106 @@ func emitRecordDeleted(ctx context.Context, app *backpack.App, tx *gorm.DB, cc *
})
}
// controllerForRecord is the ID of the one admin controller whose records
// have model's type, "" when none or several do. A nil registry knows none.
func (r *Registry) controllerForRecord(model any) string {
if r == nil || model == nil {
return ""
}
want := reflect.TypeOf(model)
found := ""
for id, cc := range r.byID {
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
continue
}
record := src.NewRecord()
if record == nil || reflect.TypeOf(record) != want {
continue
}
if found != "" {
return ""
}
found = id
}
return found
}
// reorderEvents are the RecordSaved events of a reorder save: one per node
// whose parent or sort value changed, with the change in the model's column
// types (a nil parent at the top).
func reorderEvents(cc *CompiledController, morph string, locked []AdminReorderNode, nodes []AdminReorderNodeInput) []pact.RecordSaved {
ro := cc.reorder
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
before := make(map[uint]AdminReorderNode, len(locked))
for _, n := range locked {
before[n.ID] = n
}
var out []pact.RecordSaved
for _, node := range nodes {
old, ok := before[uint(node.ID)]
if !ok {
continue
}
var changes []pact.RecordChange
if !ro.flat {
var prev, next any
if old.ParentID != nil {
prev = columnValue(proto, ro.parentColumn, *old.ParentID)
}
if node.ParentID != nil {
next = columnValue(proto, ro.parentColumn, uint(*node.ParentID))
}
if !sameChangeValue(prev, next) {
changes = append(changes, pact.RecordChange{Attribute: ro.parentColumn, Old: prev, New: next})
}
}
if old.SortOrder != node.SortOrder {
changes = append(changes, pact.RecordChange{
Attribute: ro.sortColumn,
Old: columnValue(proto, ro.sortColumn, old.SortOrder),
New: columnValue(proto, ro.sortColumn, node.SortOrder),
})
}
if len(changes) == 0 {
continue
}
out = append(out, pact.RecordSaved{
Controller: controllerID(cc),
Model: morph,
ID: strconv.FormatUint(node.ID, 10),
Changes: changes,
})
}
return out
}
// columnValue converts v to the type model's column holds (a pointer
// column's element type), so reorder changes match save changes. A column
// the model does not have, or a type v does not convert to, keeps v.
func columnValue(model any, column string, v any) any {
rv := reflect.ValueOf(model)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return v
}
rv = rv.Elem()
}
field := fieldByColumn(rv, column)
if !field.IsValid() {
return v
}
typ := field.Type()
for typ.Kind() == reflect.Pointer {
typ = typ.Elem()
}
val := reflect.ValueOf(v)
if !val.IsValid() || !val.Type().ConvertibleTo(typ) {
return v
}
return val.Convert(typ).Interface()
}
// recordMorphType is lagoon.MorphType, logged and "" when it fails.
func recordMorphType(db *gorm.DB, model any) string {
morph, err := lagoon.MorphType(db, model)

View File

@@ -0,0 +1,506 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// The D-92 record events on a real Postgres database: an acme.events plugin
// with a pages controller (translatable slug, a tree reorder page and a
// notes relation manager), a notes controller, and a contributor whose form
// extension sees the save's changes.
const rePageMorph = `Acme\Events\Page`
type rePage struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
ParentID *uint `gorm:"column:parent_id"`
SortOrder int `gorm:"column:sort_order"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
Notes []reNote `gorm:"-"`
}
func (rePage) TableName() string { return "cabana_re_pages" }
func (rePage) MorphName() string { return rePageMorph }
func (*rePage) Fillable() []string { return []string{"title", "slug"} }
func (*rePage) Rules() map[string]string { return map[string]string{"title": "required"} }
type reNote struct {
ID uint `gorm:"column:id;primaryKey"`
PageID *uint `gorm:"column:page_id"`
Body string `gorm:"column:body"`
}
func (reNote) TableName() string { return "cabana_re_notes" }
func (*reNote) Fillable() []string { return []string{"body"} }
func (*reNote) Rules() map[string]string { return map[string]string{} }
// rePages is the pages controller; failUpdate makes FormBeforeUpdate refuse.
type rePages struct{ failUpdate *bool }
func (rePages) ID() string { return "acme.events.pages" }
func (rePages) ModelName() string { return "Page" }
func (rePages) ConfigDir() string { return "controllers/pages" }
func (rePages) NewRecord() any { return &rePage{} }
func (rePages) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB { return q }
func (c rePages) FormBeforeUpdate(context.Context, any) error {
if c.failUpdate != nil && *c.failUpdate {
return errors.New("refused")
}
return nil
}
func (rePages) AdminRelationContracts() []RelationContract {
return []RelationContract{{
Name: "notes",
Kind: RelationHasMany,
NewRelated: func() any { return &reNote{} },
ForeignKey: "page_id",
Columns: map[string]string{"body": "body"},
}}
}
type reNotes struct{}
func (reNotes) ID() string { return "acme.events.notes" }
func (reNotes) ModelName() string { return "Note" }
func (reNotes) ConfigDir() string { return "controllers/notes" }
func (reNotes) NewRecord() any { return &reNote{} }
type rePlugin struct{ failUpdate *bool }
func (rePlugin) ID() string { return "acme.events" }
func (rePlugin) Requires() []string { return nil }
func (rePlugin) Register(*backpack.App) error { return nil }
func (rePlugin) Boot(*backpack.App) error { return nil }
func (rePlugin) AdminFS() fs.FS { return reFS() }
func (p rePlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rePages{failUpdate: p.failUpdate}, reNotes{}}
}
var (
_ pact.FormBeforeUpdate = rePages{}
_ AdminRelationContractProvider = rePages{}
)
func reFS() fstest.MapFS {
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
return fstest.MapFS{
"controllers/pages/config_form.yaml": file("name: Page\nform: ~/plugins/acme/events/models/page/fields.yaml\nmodelClass: Page\n"),
"controllers/pages/config_list.yaml": file("list: ~/plugins/acme/events/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n"),
"controllers/pages/config_reorder.yaml": file("title: Reorder pages\nnameFrom: title\nparentColumn: parent_id\nsortColumn: sort_order\n"),
"controllers/pages/config_relation.yaml": file("notes:\n label: Notes\n view:\n list:\n columns:\n body:\n label: Body\n toolbarButtons: create|update|delete\n manage:\n form: $/acme/events/models/note/fields.yaml\n"),
"models/page/columns.yaml": file("columns:\n title:\n label: Title\n"),
"models/page/fields.yaml": file("fields:\n title:\n type: text\n label: Title\n slug:\n type: mltext\n label: Slug\n notes:\n type: relation-manager\n relation: notes\n"),
"controllers/notes/config_form.yaml": file("name: Note\nform: ~/plugins/acme/events/models/note/fields.yaml\nmodelClass: Note\n"),
"controllers/notes/config_list.yaml": file("list: ~/plugins/acme/events/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\n"),
"models/note/columns.yaml": file("columns:\n body:\n label: Body\n"),
"models/note/fields.yaml": file("fields:\n body:\n type: text\n label: Body\n"),
}
}
// reWriter stores translations in memory per record, field and locale.
type reWriter struct {
mu sync.Mutex
data map[string]string
}
func reKey(model any, field, locale string) string {
return fmt.Sprintf("%d/%s/%s", pkUint(model), field, locale)
}
func (*reWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
func (*reWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
return []string{"en", "pl"}, nil
}
func (w *reWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.data == nil {
w.data = map[string]string{}
}
w.data[reKey(model, field, locale)] = value
return nil
}
func (w *reWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
w.mu.Lock()
defer w.mu.Unlock()
text, ok := w.data[reKey(model, field, locale)]
return text, ok, nil
}
// reListener records every record event the bus delivers.
type reListener struct {
mu sync.Mutex
saved []pact.RecordSaved
deleted []pact.RecordDeleted
}
func (l *reListener) take() ([]pact.RecordSaved, []pact.RecordDeleted) {
l.mu.Lock()
defer l.mu.Unlock()
saved, deleted := l.saved, l.deleted
l.saved, l.deleted = nil, nil
return saved, deleted
}
type reEnv struct {
db *gorm.DB
svc *service
listener *reListener
extFail *error
extSaves *[]pact.AdminFormSave
}
func newREEnv(t *testing.T, failUpdate *bool) reEnv {
t.Helper()
_, db := newListService(t)
if err := db.Migrator().DropTable(&rePage{}, &reNote{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&rePage{}, &reNote{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Migrator().DropTable(&rePage{}, &reNote{}) })
var (
extFail error
extSaves []pact.AdminFormSave
)
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n extra:\n type: text\n label: Extra\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.events.pages",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
extSaves = append(extSaves, in)
return pact.AdminFormSaveResult{}, extFail
}},
}},
}
reg, err := feCompile(rePlugin{failUpdate: failUpdate}, extra)
if err != nil {
t.Fatalf("compile: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
if err := app.Publish[TranslationWriter](&reWriter{}); err != nil {
t.Fatal(err)
}
l := &reListener{}
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordSaved) error {
l.mu.Lock()
defer l.mu.Unlock()
l.saved = append(l.saved, *ev)
return nil
})
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordDeleted) error {
l.mu.Lock()
defer l.mu.Unlock()
l.deleted = append(l.deleted, *ev)
return nil
})
return reEnv{db: db, svc: &service{app: app, reg: reg}, listener: l, extFail: &extFail, extSaves: &extSaves}
}
func (e reEnv) pages(t *testing.T) *CompiledController {
t.Helper()
cc, ok := e.svc.reg.Get("acme.events.pages")
if !ok {
t.Fatal("pages controller missing")
}
return cc
}
func (e reEnv) crud(t *testing.T) CRUDService {
t.Helper()
svc, err := e.svc.crud()
if err != nil {
t.Fatal(err)
}
return svc
}
func (e reEnv) relations(t *testing.T) RelationService {
t.Helper()
svc, err := e.svc.relations()
if err != nil {
t.Fatal(err)
}
return svc
}
// reCtx is an administrator's request context with the content locale pl.
func reCtx() context.Context {
return towel.WithLocale(bouncer.WithUser(context.Background(), superUser()), "pl")
}
func reID(t *testing.T, data map[string]any) uint {
t.Helper()
switch v := data["id"].(type) {
case uint:
return v
case float64:
return uint(v)
case json.Number:
n, _ := v.Int64()
return uint(n)
}
n, err := strconv.ParseUint(fmt.Sprint(data["id"]), 10, 64)
if err != nil {
t.Fatalf("record id %v: %v", data["id"], err)
}
return uint(n)
}
func oneSaved(t *testing.T, l *reListener) pact.RecordSaved {
t.Helper()
saved, deleted := l.take()
if len(saved) != 1 || len(deleted) != 0 {
t.Fatalf("events: saved %+v deleted %+v, want one RecordSaved", saved, deleted)
}
return saved[0]
}
func noEvents(t *testing.T, l *reListener) {
t.Helper()
if saved, deleted := l.take(); len(saved) != 0 || len(deleted) != 0 {
t.Fatalf("events after a refused write: saved %+v deleted %+v", saved, deleted)
}
}
// TestRecordEvents (D-92): every admin write path fires RecordSaved or
// RecordDeleted after the commit, with the changes per locale, and a
// rolled-back write fires nothing.
func TestRecordEvents(t *testing.T) {
failUpdate := false
env := newREEnv(t, &failUpdate)
cc := env.pages(t)
crud := env.crud(t)
ctx := reCtx()
var homeID uint
t.Run("create", func(t *testing.T) {
res, err := crud.Create(ctx, cc, RecordInput{Body: map[string]any{"title": "Home", "slug": map[string]any{"en": "home", "pl": "dom"}}})
if err != nil {
t.Fatal(err)
}
homeID = reID(t, res)
ev := oneSaved(t, env.listener)
want := pact.RecordSaved{
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(homeID), 10), Locale: "pl", Created: true,
Changes: []pact.RecordChange{
{Attribute: "title", New: "Home"},
{Attribute: "slug", New: "home"},
{Attribute: "slug", Locale: "pl", New: "dom"},
},
}
if !reflect.DeepEqual(ev, want) {
t.Fatalf("event = %+v\nwant %+v", ev, want)
}
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want.Changes) {
t.Fatalf("extension Save changes = %+v, want the event's", saves)
}
})
update := func(body map[string]any) (map[string]any, error) {
return crud.Update(ctx, cc, homeID, RecordInput{Body: body})
}
t.Run("update", func(t *testing.T) {
*env.extSaves = nil
if _, err := update(map[string]any{"title": "Start"}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "title", Old: "Home", New: "Start"}}
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("event = %+v, want changes %+v", ev, want)
}
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want) {
t.Fatalf("extension Save changes = %+v, want %+v", saves, want)
}
})
t.Run("translated slug", func(t *testing.T) {
if _, err := update(map[string]any{"slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "slug", Locale: "pl", Old: "dom", New: "strona"}}
if !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("changes = %+v, want %+v", ev.Changes, want)
}
})
t.Run("no-op update", func(t *testing.T) {
if _, err := update(map[string]any{"title": "Start", "slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
if len(ev.Changes) != 0 || ev.ID != strconv.FormatUint(uint64(homeID), 10) {
t.Fatalf("no-op event = %+v, want no changes", ev)
}
})
t.Run("rollback", func(t *testing.T) {
if _, err := update(map[string]any{"title": ""}); err == nil {
t.Fatal("empty title saved")
}
noEvents(t, env.listener)
failUpdate = true
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
t.Fatal("FormBeforeUpdate refusal saved")
}
failUpdate = false
noEvents(t, env.listener)
*env.extFail = pact.AdminFormErrors{"extra": {"bad"}}
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
t.Fatal("extension Save refusal saved")
}
*env.extFail = nil
noEvents(t, env.listener)
var page rePage
if err := env.db.First(&page, homeID).Error; err != nil || page.Title != "Start" {
t.Fatalf("page after refusals = %+v err=%v", page, err)
}
})
t.Run("failing listener", func(t *testing.T) {
env.svc.app.Events.Listen("test.failing", func(context.Context, *pact.RecordSaved) error {
return errors.New("listener down")
})
if _, err := update(map[string]any{"title": "Again"}); err != nil {
t.Fatalf("save failed with a failing listener: %v", err)
}
ev := oneSaved(t, env.listener)
if len(ev.Changes) != 1 || ev.Changes[0].New != "Again" {
t.Fatalf("event = %+v", ev)
}
})
t.Run("reorder", func(t *testing.T) {
child := rePage{Title: "Child", Slug: "child", SortOrder: 2}
if err := env.db.Create(&child).Error; err != nil {
t.Fatal(err)
}
if err := env.db.Model(&rePage{}).Where("id = ?", homeID).Update("sort_order", 1).Error; err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]any{"nodes": []map[string]any{
{"id": homeID, "parent_id": nil, "sort_order": 1},
{"id": child.ID, "parent_id": homeID, "sort_order": 1},
}})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "events")
req.SetPathValue("controller", "pages")
req = req.WithContext(bouncer.WithUser(req.Context(), superUser()))
rec := httptest.NewRecorder()
env.svc.reorderPost(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", rec.Code, rec.Body.String())
}
ev := oneSaved(t, env.listener)
want := pact.RecordSaved{
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(child.ID), 10),
Changes: []pact.RecordChange{
{Attribute: "parent_id", Old: nil, New: homeID},
{Attribute: "sort_order", Old: 2, New: 1},
},
}
ev.Locale = ""
if !reflect.DeepEqual(ev, want) {
t.Fatalf("reorder event = %+v\nwant %+v", ev, want)
}
})
t.Run("child write", func(t *testing.T) {
rel := env.relations(t)
created, err := rel.CreateChild(ctx, cc, "notes", homeID, RecordInput{Body: map[string]any{"body": "first"}})
if err != nil {
t.Fatal(err)
}
noteID := reID(t, created.Data)
ev := oneSaved(t, env.listener)
if ev.Controller != "acme.events.notes" || ev.Model != "cabana_re_notes" || !ev.Created || ev.ID != strconv.FormatUint(uint64(noteID), 10) {
t.Fatalf("child create event = %+v", ev)
}
if _, err := rel.UpdateChild(ctx, cc, "notes", homeID, noteID, RecordInput{Body: map[string]any{"body": "second"}}); err != nil {
t.Fatal(err)
}
ev = oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "body", Old: "first", New: "second"}}
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("child update event = %+v, want changes %+v", ev, want)
}
if _, err := rel.DeleteChildren(ctx, cc, "notes", homeID, BulkDeleteInput{IDs: []any{float64(noteID)}}); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
wantDel := []pact.RecordDeleted{{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: strconv.FormatUint(uint64(noteID), 10)}}
if len(saved) != 0 || !reflect.DeepEqual(deleted, wantDel) {
t.Fatalf("child delete events: saved %+v deleted %+v", saved, deleted)
}
})
t.Run("delete", func(t *testing.T) {
victim := rePage{Title: "Victim", Slug: "victim"}
if err := env.db.Create(&victim).Error; err != nil {
t.Fatal(err)
}
if _, err := crud.Delete(ctx, cc, victim.ID); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
want := []pact.RecordDeleted{{Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(victim.ID), 10)}}
if len(saved) != 0 || !reflect.DeepEqual(deleted, want) {
t.Fatalf("delete events: saved %+v deleted %+v", saved, deleted)
}
})
t.Run("bulk delete", func(t *testing.T) {
a, b := rePage{Title: "A", Slug: "a"}, rePage{Title: "B", Slug: "b"}
for _, p := range []*rePage{&a, &b} {
if err := env.db.Create(p).Error; err != nil {
t.Fatal(err)
}
}
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(999999)}}); err == nil {
t.Fatal("partial selection deleted")
}
noEvents(t, env.listener)
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(b.ID)}}); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
if len(saved) != 0 || len(deleted) != 2 {
t.Fatalf("bulk delete events: saved %+v deleted %+v", saved, deleted)
}
got := map[string]bool{deleted[0].ID: true, deleted[1].ID: true}
if !got[strconv.FormatUint(uint64(a.ID), 10)] || !got[strconv.FormatUint(uint64(b.ID), 10)] || deleted[0].Model != rePageMorph {
t.Fatalf("bulk delete events = %+v", deleted)
}
})
}

View File

@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
@@ -200,6 +201,10 @@ type RelationService struct {
bucket *blob.Bucket
tr *phrasebook.Translator
writer TranslationWriter
// app receives the record events of child writes; reg names the
// controller that edits a child's model. Both may be nil.
app *backpack.App
reg *Registry
}
func (s RelationSchema) MarshalJSON() ([]byte, error) {

View File

@@ -12,6 +12,7 @@ import (
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -192,6 +193,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
return err
}
changes, err := recordChanges(ctx, tx, s.writer, child, nil, translations)
if err != nil {
return err
}
switch {
case parent.unsaved():
// The child exists now, unattached; the parent's first save
@@ -213,6 +218,7 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController
return lifecycleFailure(cc, err)
}
}
s.emitChildSaved(ctx, tx, child, true, changes)
result, err = projectFullRecord(ctx, tx, cr.child, child)
if err != nil {
return err
@@ -342,6 +348,10 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
if err != nil {
return err
}
before, err := takeRecordSnapshot(ctx, tx, s.writer, child, translations)
if err != nil {
return err
}
if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil {
return err
}
@@ -356,6 +366,10 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil {
return err
}
changes, err := recordChanges(ctx, tx, s.writer, child, before, translations)
if err != nil {
return err
}
if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil {
return err
}
@@ -364,6 +378,7 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController
return lifecycleFailure(cc, err)
}
}
s.emitChildSaved(ctx, tx, child, false, changes)
result, err = projectFullRecord(ctx, tx, cr.child, child)
if err != nil {
return err
@@ -455,9 +470,34 @@ func (s RelationService) deleteChild(ctx context.Context, tx *gorm.DB, cc *Compi
return lifecycleFailure(cc, err)
}
}
if s.app != nil && s.app.Events != nil {
EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{
Controller: s.reg.controllerForRecord(child),
Model: recordMorphType(tx, child),
ID: recordIDText(child),
})
}
return nil
}
// emitChildSaved fires the RecordSaved of a child a relation manager wrote,
// after tx commits. Controller is the controller that edits the child's
// model, "" when no single one does.
func (s RelationService) emitChildSaved(ctx context.Context, tx *gorm.DB, child any, created bool, changes []pact.RecordChange) {
if s.app == nil || s.app.Events == nil {
return
}
locale, _ := towel.Locale(ctx)
EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{
Controller: s.reg.controllerForRecord(child),
Model: recordMorphType(tx, child),
ID: recordIDText(child),
Locale: locale,
Created: created,
Changes: changes,
})
}
// loadPivotRow loads this parent's pivot row for the related id, locked; a
// missing row is recordNotFound.
func loadPivotRow(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, parent *relationParent, childID uint) (any, error) {

View File

@@ -332,6 +332,14 @@ func (s *service) reorderPost(w http.ResponseWriter, r *http.Request) {
}
proto := cc.Controller.(pact.AdminRecordSource).NewRecord()
pk := primaryColumn(proto)
// D-92: one RecordSaved per moved node, after the commit.
if s.app != nil && s.app.Events != nil {
morph := recordMorphType(tx, proto)
for _, ev := range reorderEvents(cc, morph, locked.Nodes, in.Nodes) {
ev.Locale, _ = towel.Locale(lctx)
EmitRecordSaved(lctx, s.app, tx, ev)
}
}
for _, node := range in.Nodes {
var parent any
if node.ParentID != nil {