feat(12.2-03): defer relation work on unsaved records and add child file routes

- record id 0 with X-Session-Key manages deferrable relations: create, link, unlink, delete and pivot edits are held in deferred_bindings
- the record's create save applies relation bindings with the file bindings; an ineligible link is a 422 on the relation-manager field
- child forms upload files through .../records/{child}/files/{field} keyed by X-Child-Session-Key; the child save commits them
- boot refuses a deferrable relation with create whose related model no plugin lists in Models()
This commit is contained in:
Jakub Zych
2026-10-02 19:08:16 +02:00
parent afb05b6ee4
commit fe9e8baaf1
16 changed files with 3643 additions and 436 deletions

View File

@@ -2,10 +2,14 @@ package cabana
import (
"fmt"
"reflect"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
type controllerRef struct {
@@ -332,3 +336,83 @@ func (r *Registry) rolePermissions(role string) map[string]bool {
}
return out
}
// checkDeferredModels stops boot when a deferrable relation that declares
// create points at a related model no activated plugin lists in
// pact.HasModels Models(): deferred:purge resolves the slave type of an
// abandoned child through those models and could not delete it (RESEARCH
// Pitfall 9). A listed model of the same Go type matches; with a database,
// a listed model of the same morph type matches too.
func checkDeferredModels(reg *Registry, plugins []party.Plugin, gdb *gorm.DB) error {
if reg == nil {
return nil
}
var models []any
for _, p := range plugins {
if hm, ok := p.(pact.HasModels); ok && hm != nil {
for _, m := range hm.Models() {
if m != nil {
models = append(models, m)
}
}
}
}
ids := make([]string, 0, len(reg.byID))
for id := range reg.byID {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
cc := reg.byID[id]
names := make([]string, 0, len(cc.Relations))
for name := range cc.Relations {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
cr := cc.Relations[name]
if !cr.deferrable || !cr.allows("create") {
continue
}
related := cr.Contract.NewRelated()
if !modelListed(models, related, gdb) {
morph := tableName(related)
if gdb != nil {
if m, err := lagoon.MorphType(gdb, related); err == nil {
morph = m
}
} else if owner, ok := related.(interface{ MorphName() string }); ok {
morph = owner.MorphName()
}
return fmt.Errorf("cabana: admin controller %s/%s: relation %s creates %s records under deferral, but no activated plugin lists that model in Models(), so deferred:purge could not remove abandoned ones", cc.PluginID, id, name, morph)
}
}
}
return nil
}
func modelListed(models []any, related any, gdb *gorm.DB) bool {
want := reflect.TypeOf(related)
for want.Kind() == reflect.Pointer {
want = want.Elem()
}
var morph string
if gdb != nil {
morph, _ = lagoon.MorphType(gdb, related)
}
for _, m := range models {
t := reflect.TypeOf(m)
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == want {
return true
}
if morph != "" {
if got, err := lagoon.MorphType(gdb, m); err == nil && got == morph {
return true
}
}
}
return false
}