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

@@ -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.