fix(32): WR-01 fire record events for hasMany link, unlink and deferred children
hasMany link and unlink rewrote a child's foreign key (and ClearOnChange
columns) without a RecordSaved, the deferred commit attached session-created
children silently, and unlinking a session-created child deleted it without
the RecordDeleted that should follow its RecordSaved{Created}. These paths
now fire the child's events after the commit; the CRUD service carries the
registry so the deferred commit names the child's controller.
This commit is contained in:
@@ -222,7 +222,7 @@ 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).
|
||||
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), relation-manager child create and update, and a hasMany link or unlink (for the child, with its foreign key change, also when a parent's first save attaches the children created in its form), `pact.RecordDeleted` from delete, each row of a bulk delete, relation-manager child delete and the unlink of a child created in an unsaved parent's form. 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
|
||||
|
||||
|
||||
@@ -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 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`.
|
||||
- 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 hasMany link or unlink, and the attach of a child created in an unsaved parent's form session, fire `pact.RecordSaved` for the child with its foreign key change, and a session-created child that is unlinked again fires `pact.RecordDeleted`. 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.
|
||||
|
||||
@@ -34,8 +34,10 @@ type CRUDService struct {
|
||||
// fields. Nil when the translate plugin is not mounted.
|
||||
writer TranslationWriter
|
||||
// app receives the record events of the service's writes
|
||||
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none.
|
||||
// (pact.RecordSaved, pact.RecordDeleted). Nil fires none. reg names
|
||||
// the controller of a related record a save rewrites; it may be nil.
|
||||
app *backpack.App
|
||||
reg *Registry
|
||||
}
|
||||
|
||||
// RecordInput is a decoded JSON object. Keys are untrusted. SessionKey is
|
||||
|
||||
@@ -114,7 +114,7 @@ func (s CRUDService) commitDeferred(ctx context.Context, tx *gorm.DB, cc *Compil
|
||||
if err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
||||
rel := RelationService{DB: s.DB, bucket: s.bucket, tr: s.tr, app: s.app, reg: s.reg}
|
||||
parent := &relationParent{model: target, id: pkUint(target)}
|
||||
applied := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
@@ -224,10 +224,9 @@ func (s RelationService) applyRelationBinding(ctx context.Context, tx *gorm.DB,
|
||||
if fk, ok := structFieldValue(child, cr.Contract.ForeignKey); !ok || !fk.IsNil() {
|
||||
return true, nil
|
||||
}
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, parent.id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.WithContext(ctx).Save(child).Error
|
||||
return true, s.saveLinkedChild(ctx, tx, child, func() error {
|
||||
return setModelColumn(child, cr.Contract.ForeignKey, parent.id)
|
||||
})
|
||||
}
|
||||
linked, err := pendingRelationIDs(tx, cr, parent.id, []uint{id})
|
||||
if err != nil || len(linked) == 0 {
|
||||
|
||||
@@ -1127,7 +1127,7 @@ func (s *service) crud() (CRUDService, error) {
|
||||
if err != nil {
|
||||
return CRUDService{}, err
|
||||
}
|
||||
svc := CRUDService{DB: db, bucket: s.bucket(), tr: s.translator(), app: s.app}
|
||||
svc := CRUDService{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
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"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"
|
||||
@@ -465,6 +466,86 @@ func TestRecordEvents(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hasMany link and unlink", func(t *testing.T) {
|
||||
// The link candidates exclude children pending in a form session.
|
||||
if err := env.db.AutoMigrate(&lagoon.DeferredBinding{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loose := reNote{Body: "loose"}
|
||||
if err := env.db.Create(&loose).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := env.relations(t)
|
||||
noteID := strconv.FormatUint(uint64(loose.ID), 10)
|
||||
if _, err := rel.Link(ctx, cc, "notes", homeID, RelationMutationInput{IDs: []any{float64(loose.ID)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := oneSaved(t, env.listener)
|
||||
want := pact.RecordSaved{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: noteID, Locale: "pl",
|
||||
Changes: []pact.RecordChange{{Attribute: "page_id", Old: nil, New: homeID}}}
|
||||
if !reflect.DeepEqual(ev, want) {
|
||||
t.Fatalf("link event = %+v\nwant %+v", ev, want)
|
||||
}
|
||||
if _, err := rel.Unlink(ctx, cc, "notes", homeID, RelationMutationInput{IDs: []any{float64(loose.ID)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev = oneSaved(t, env.listener)
|
||||
want.Changes = []pact.RecordChange{{Attribute: "page_id", Old: homeID, New: nil}}
|
||||
if !reflect.DeepEqual(ev, want) {
|
||||
t.Fatalf("unlink event = %+v\nwant %+v", ev, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deferred children", func(t *testing.T) {
|
||||
if err := env.db.AutoMigrate(&lagoon.DeferredBinding{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := env.relations(t)
|
||||
rel.SessionKey = "re-events-session-key-0123456789abcdef"
|
||||
// A child created in the session and unlinked again is deleted:
|
||||
// its RecordSaved{Created} is followed by a RecordDeleted.
|
||||
dropped, err := rel.CreateChild(ctx, cc, "notes", 0, RecordInput{Body: map[string]any{"body": "dropped"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
droppedID := reID(t, dropped.Data)
|
||||
if ev := oneSaved(t, env.listener); !ev.Created {
|
||||
t.Fatalf("deferred create event = %+v", ev)
|
||||
}
|
||||
if _, err := rel.Unlink(ctx, cc, "notes", 0, RelationMutationInput{IDs: []any{float64(droppedID)}}); 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(droppedID), 10)}}
|
||||
if len(saved) != 0 || !reflect.DeepEqual(deleted, wantDel) {
|
||||
t.Fatalf("deferred unlink events: saved %+v deleted %+v", saved, deleted)
|
||||
}
|
||||
// A child created in the session is attached by the parent's first
|
||||
// save: it gets its RecordSaved with the new key.
|
||||
kept, err := rel.CreateChild(ctx, cc, "notes", 0, RecordInput{Body: map[string]any{"body": "kept"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptID := reID(t, kept.Data)
|
||||
env.listener.take()
|
||||
page, err := crud.Create(ctx, cc, RecordInput{Body: map[string]any{"title": "Deferred"}, SessionKey: rel.SessionKey})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pageID := reID(t, page)
|
||||
saved, _ = env.listener.take()
|
||||
var child *pact.RecordSaved
|
||||
for i := range saved {
|
||||
if saved[i].Model == "cabana_re_notes" {
|
||||
child = &saved[i]
|
||||
}
|
||||
}
|
||||
want := []pact.RecordChange{{Attribute: "page_id", Old: nil, New: pageID}}
|
||||
if len(saved) != 2 || child == nil || child.ID != strconv.FormatUint(uint64(keptID), 10) || child.Created || !reflect.DeepEqual(child.Changes, want) {
|
||||
t.Fatalf("deferred commit events = %+v, want the page and the attached child", saved)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
victim := rePage{Title: "Victim", Slug: "victim"}
|
||||
if err := env.db.Create(&victim).Error; err != nil {
|
||||
|
||||
@@ -1002,15 +1002,15 @@ func (s RelationService) linkRelated(ctx context.Context, tx *gorm.DB, cc *Compi
|
||||
for i := 0; i < holder.Elem().Len(); i++ {
|
||||
related := holder.Elem().Index(i).Addr().Interface()
|
||||
if cr.hasMany() {
|
||||
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
// A link always changes the key: it adopts only children whose
|
||||
// key is NULL.
|
||||
if err := clearColumns(related, cr.Contract.ClearOnChange, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).Save(related).Error; err != nil {
|
||||
err := s.saveLinkedChild(ctx, tx, related, func() error {
|
||||
if err := setModelColumn(related, cr.Contract.ForeignKey, ownerPK); err != nil {
|
||||
return err
|
||||
}
|
||||
// A link always changes the key: it adopts only children
|
||||
// whose key is NULL.
|
||||
return clearColumns(related, cr.Contract.ClearOnChange, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
linked++
|
||||
@@ -1130,6 +1130,12 @@ func (s RelationService) unlinkDeferred(ctx context.Context, tx *gorm.DB, cc *Co
|
||||
if err := tx.WithContext(ctx).Delete(child).Error; err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
// CreateChild announced the child; its removal is announced too.
|
||||
EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{
|
||||
Controller: s.reg.controllerForRecord(child),
|
||||
Model: recordMorphType(tx, child),
|
||||
ID: recordIDText(child),
|
||||
})
|
||||
}
|
||||
return len(bound), nil
|
||||
}
|
||||
@@ -1307,13 +1313,13 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
|
||||
}
|
||||
for i := 0; i < holder.Elem().Len(); i++ {
|
||||
child := holder.Elem().Index(i).Addr().Interface()
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := clearColumns(child, cr.Contract.ClearOnChange, nil); err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
|
||||
err := s.saveLinkedChild(ctx, tx, child, func() error {
|
||||
if err := setModelColumn(child, cr.Contract.ForeignKey, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return clearColumns(child, cr.Contract.ClearOnChange, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
@@ -1331,6 +1337,36 @@ func (s RelationService) unlinkRelated(ctx context.Context, tx *gorm.DB, cc *Com
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// saveLinkedChild applies change to a hasMany child (its foreign key and
|
||||
// ClearOnChange columns), saves it and fires its RecordSaved with what
|
||||
// changed after tx commits (D-92): link, unlink and the deferred commit
|
||||
// rewrite children through it.
|
||||
func (s RelationService) saveLinkedChild(ctx context.Context, tx *gorm.DB, child any, change func() error) error {
|
||||
events := s.app != nil && s.app.Events != nil
|
||||
var before *recordSnapshot
|
||||
if events {
|
||||
var err error
|
||||
if before, err = takeRecordSnapshot(ctx, tx, nil, child, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := change(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Save(child).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !events {
|
||||
return nil
|
||||
}
|
||||
changes, err := recordChanges(ctx, tx, nil, child, before, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.emitChildSaved(ctx, tx, child, false, changes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockPivotRows loads this parent's pivot rows for the related ids FOR
|
||||
// UPDATE, in related id order.
|
||||
func lockPivotRows(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, ownerPK uint, ids []uint) ([]any, error) {
|
||||
|
||||
@@ -670,7 +670,7 @@ func (s RelationService) updatePendingPivot(ctx context.Context, tx *gorm.DB, cc
|
||||
// X-Child-Session-Key session, in.SessionKey) to the saved child inside the
|
||||
// child's transaction, then rechecks the child's file limits (D-17).
|
||||
func (s RelationService) commitChildFiles(ctx context.Context, tx *gorm.DB, cr *CompiledRelation, child any, op string, in RecordInput) error {
|
||||
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr}
|
||||
crud := CRUDService{DB: s.DB, bucket: s.bucket, tr: s.tr, app: s.app, reg: s.reg}
|
||||
return crud.commitDeferred(ctx, tx, cr.child, child, op, RecordInput{SessionKey: in.SessionKey})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user