Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
16 KiB
Phase 12.2: Admin form fields (date, file upload, relation editing with deferred binding) - Pattern Map
Mapped: 2026-10-02 Files analyzed: 26 (new + modified) Analogs found: 24 / 26
All analog paths are git-tracked sources in summercms.go (no mirrors).
File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
modules/lagoon/date.go (new) |
model value type | transform (Scanner/Valuer/JSON/Text) | modules/lagoon/encrypted.go |
role-match |
modules/lagoon/deferred.go (new) |
model + store | CRUD | modules/lagoon/attach/file.go (File model, DeleteForOwner) |
role-match |
modules/lagoon/deferred_migrations.go (new) |
migration | batch DDL | modules/lagoon/attach/migrations.go |
exact |
modules/lagoon/migrations.go (mod) |
migration runner | batch | itself, Migrate lines 63-108 |
exact |
modules/lagoon/fill.go (mod) |
utility | transform | itself, convertValue lines 112-155, 172-193 |
exact |
modules/lagoon/validate.go (mod) |
utility | transform | itself, isEmptyValue line 264 |
exact |
modules/lagoon/commands.go (mod, deferred:purge) |
CLI command | batch | itself, RuntimeCommands migrate entry lines 16-30 |
exact |
modules/lagoon/schedule.go (new) |
config/provider | batch | modules/conga/scheduler.go scheduleEntries 63-99 + pact.HasSchedule |
role-match |
modules/lagoon/attach/store.go (new) |
service | file-I/O | modules/lagoon/attach/thumb.go (bucket writes) + file.go DeleteKeys |
role-match |
modules/lagoon/attach/guard.go (new) |
utility | transform | none in framework (app classes/image_guard.go); thumb pixel guard attach/thumb.go:24-28 |
partial |
modules/lagoon/attach/relation.go (new) |
contract/interface | n/a | attach.Owner in attach/file.go:17-22 |
role-match |
modules/cabana/form_schema.go (mod) |
config compiler | transform | itself, compileWidgetKeys 458-499 |
exact |
modules/cabana/field_date.go (new) |
config compiler | transform | compileWidgetKeys (form_schema.go) |
role-match |
modules/cabana/field_file.go (new) |
controller | file-I/O / request-response | modules/cabana/relation.go handlers + Link 625-692 |
role-match |
modules/cabana/deferred.go (new) |
service | CRUD (tx) | RelationService.Link relation.go 625-692; CRUDService.save crud.go 310-386 |
role-match |
modules/cabana/relation.go / relation_child.go |
service + controller | CRUD | relation.go Link/contract/compileRelationButtons 314-336 |
exact |
modules/cabana/crud.go (mod: RecordInput.SessionKey, save commit, scalarFormField) |
service | CRUD | itself | exact |
modules/cabana/list_schema.go (mod: isListRelation, date/time column types) |
config compiler | transform | model_fields.go:70-82 embeddedStructType |
exact |
modules/cabana/schema_types.go (mod: FormField flat keys) |
model (DTO) | n/a | existing Widget/Action/Fill/Path fields 197-229 |
exact |
modules/cabana/schema.go (mod: assetPath $/) |
utility | transform | itself 37-47 | exact |
modules/cabana/http.go (mod) |
route | request-response | itself, mount 248-259 + nestedGet 299-313 |
exact |
modules/cabana/admin_openapi.go (mod) |
docs/annotations | n/a | AdminRelationLink 575-591 |
exact |
modules/pact/capabilities.go (mod) |
interface | event-driven hooks | RelationBeforeLink 389-392 |
exact |
modules/conga/scheduler.go (mod) |
scheduler | batch | itself scheduleEntries 63-99 |
exact |
admin/src/components/form/fields/DatepickerField.vue, FileuploadField.vue (new) + form/registry.ts (mod) |
component | request-response | fields/DropdownField.vue + registry.ts 10-41 |
role-match |
admin/src/components/relation/RelationChildModal.vue, RelationPivotModal.vue (new) |
component | request-response | relation/RelationPickerModal.vue |
exact |
admin/src/app/sessionKey.ts, admin/src/app/dateFormat.ts (new) |
utility | transform | admin/src/app/listQuery.ts, winterUrl.ts (small pure helpers) |
partial |
| Tests (last plan) | test | — | modules/cabana/relation_test.go, modules/lagoon/attach/migrations+file_test.go, phase09_contract_test.go (TestPhase09PermissionMatrix) |
exact |
Pattern Assignments
modules/lagoon/deferred_migrations.go (migration)
Analog: modules/lagoon/attach/migrations.go (whole file, 1-48)
var Migrations = []*gormigrate.Migration{
{
ID: "202609180001_create_system_files",
Migrate: func(tx *gorm.DB) error {
stmts := []string{ `CREATE TABLE system_files (...)`, `CREATE INDEX ...` }
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil { return err }
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS system_files").Error
},
},
}
Name the var DeferredBindingMigrations; ID like 2026100200NN_create_deferred_bindings; DDL per RESEARCH "Code Examples" (adds backend_user_id INTEGER NOT NULL, indexes on master_type/master_field/slave_type/slave_id/session_key).
modules/lagoon/migrations.go (modify Migrate)
Analog: lines 67-80. Insert after the summercms.cabana block, same shape:
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
if err != nil { return err }
if err := admin.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate backend admin: %w", err)
}
Use a new history id (e.g. "summercms.deferred"); also wire into RollbackLast/status lookups that switch on framework ids.
modules/lagoon/date.go (Date, TimeOfDay)
Analog: modules/lagoon/encrypted.go Scan 62-93 / Value 97-102:
func (e *Encrypted) Scan(src any) error {
if e == nil { return fmt.Errorf("lagoon: encrypted scan on nil receiver") }
if src == nil { e.plaintext = nil; e.set = false; return nil }
var raw string
switch v := src.(type) {
case string: raw = v
case []byte: raw = string(v)
default: return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src)
}
...
}
func (e Encrypted) Value() (driver.Value, error) { if !e.set { return nil, nil } ... }
Add time.Time source case for Date (pgx returns time.Time for DATE), plus MarshalJSON/UnmarshalText/MarshalText/IsZero. Error prefix lagoon: date ....
modules/lagoon/fill.go / validate.go
Edit in place: convertValue (fill.go 112-155, pointer path 172-193) gets an encoding.TextUnmarshaler fallback only after ConvertibleTo fails and only for string/[]byte sources (RESEARCH Pitfall 1). isEmptyValue (validate.go 264-277) gets interface{ IsZero() bool } (Pitfall 3). Update modules/lagoon/README.md.
modules/lagoon/deferred.go (model + store)
Analog: modules/lagoon/attach/file.go — TableName() (line 47), DeleteForOwner(tx, owner, ownerID, afterCommit) (130), DeleteKeys(ctx, bucket, keys) (157). Store ops take *gorm.DB tx as first arg and defer blob deletes via lagoon.AfterCommit(ctx, db, fn) (transaction.go:103). Never delete blobs inside the tx.
modules/lagoon/commands.go (+ deferred:purge)
Analog: RuntimeCommands lines 16-30:
{
Name: "migrate",
Description: "Run plugin migrations in dependency order",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return withDB(ctx, app, func(gdb *gorm.DB) error {
if err := Migrate(gdb, plugins); err != nil { return err }
out.Success("migrations applied")
return nil
})
},
},
Flags follow migrate:rollback's []bonfire.Flag{{Name, Description}} (lines 33-37). Resolve slave types via pact.HasModels over plugins (Pitfall 9). Update the "RuntimeCommands returns ..." doc comment and README CLI section; docs checker picks up the name.
modules/lagoon/schedule.go + modules/conga/scheduler.go
Analog: conga/scheduler.go 63-99. Prepend framework entries before the plugin loop using the same compile path:
sched, period, err := scheduleFor(sc.Cadence, loc)
...
out = append(out, scheduleEntry{
id: fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command),
plugin: p.ID(), index: i, cmd: sc, schedule: sched, period: period,
})
with plugin id "summercms.lagoon". FrameworkSchedule(app) []pact.ScheduledCommand returns {Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}.
modules/lagoon/attach/store.go, guard.go, relation.go
- Body limits: copy
http.MaxBytesReaderusage frommodules/wristband/register.go:67/cabana/auth.go:159; addio.LimitReader(part, max+1)per part. - Pixel ceiling: reuse
maxThumbSourcePixels(attach/thumb.go:24-28). - Deletion of blobs/thumbs:
blobKeysFor(f)(file.go 110) +DeleteKeys. relation.go: interface besideattach.Owner(file.go 17-22) —type HasRelations interface { AttachRelations() []Relation }.- Guard: no framework analog — port from the application's
classes/image_guard.goper RESEARCH Pitfall 5 (DetectContentType + image.DecodeConfig, fail closed, webp viagolang.org/x/image/webponly if already a dependency; check go.mod).
modules/cabana/form_schema.go + field_date.go + field_file.go (compile)
Analog: compileWidgetKeys 458-499:
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != "widget" {
for _, key := range widgetKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is only valid on type: widget", key)
}
}
return nil
}
tag, err := nodeString(values["widget"])
if err != nil || strings.TrimSpace(tag) == "" {
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
}
field.Widget = tag
...
}
Write compileDatepickerKeys / compileFileuploadKeys identically (key lists datepickerKeys, fileuploadKeys; mode shared, validated per type). Register types in formFieldTypes, keys in formFieldKeys (22-42). Add datepicker to scalarFormField (crud.go:800). New flat omitempty fields on FormField (schema_types.go:197-229).
modules/cabana/deferred.go, relation_child.go, field_file.go handlers (tx services)
Analog: RelationService.Link (relation.go 625-692):
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
parent, err := newWritableModel(cc)
if err != nil { return err }
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { return err }
ownerPK := pkUint(parent)
...
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
...
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
return lifecycleFailure(cc, err)
}
}
...
})
Rules: every child query is parent-scoped (clause.Eq + quotedIdent), miss returns recordNotFound{} → 404 via writeCRUDError (crud.go 393-410); validation errors via relationInvalid(field, msg). Commit hook goes in CRUDService.save (crud.go 310-386) after syncBelongsToMany (~373), before formAfterCreate/Update (376-383). Admin id from bouncer.User(ctx).
Button compile: extend compileRelationButtons (relation.go 314-336) accepted set to create|update|delete|link|unlink. Contract: add Kind, ForeignKey to RelationContract (27-36), branch in validateRelationContract (338-394).
modules/cabana/http.go (routes)
Analog: lines 248-259 and nestedGet 299-313:
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g)
...
case segment == "relations":
s.relationLinked(w, r)
Each new route: g.<Method>(path, requireAjax(handler)) for writes, followed by its constrain* call; add case segment == "files": s.fileList(w, r) to nestedGet. ServeMux conflicts panic in TestPhase09PermissionMatrix.
modules/cabana/admin_openapi.go
Analog: AdminRelationLink (575-591): doc func with @Summary, @Tags admin, @Security BackendBearer, @Param per path segment, @Success 200 {object} Envelope[T], @Failure 401/403/422/404 {object} ErrorEnvelope, @Router ... [method], body func AdminX() {}. Uploads use @Accept multipart/form-data and @Param file_data formData file true. Regenerate via scripts/check-admin-openapi.sh.
modules/pact/capabilities.go
Analog: lines 389-392:
// RelationBeforeLink optionally stamps pivot columns before a link insert.
type RelationBeforeLink interface {
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
}
New Relation{Before,After}{Create,Update,Delete} follow the same one-method optional-interface shape; call sites type-assert cc.Controller.(pact.X) and wrap errors with lifecycleFailure(cc, err).
SPA: DatepickerField.vue, FileuploadField.vue
Analog: admin/src/components/form/fields/DropdownField.vue 1-60:
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: ...] }>()
Template binds :id="controlId", :aria-invalid="invalid ? 'true' : undefined", :aria-describedby="describedBy || undefined", :aria-required. Register in admin/src/components/form/registry.ts (imports 10-20, map entries 32-41: ['datepicker', DatepickerField], ['fileupload', FileuploadField]). Visuals per 12.2-UI-SPEC.md; Reka DatePicker/DateField + @internationalized/date.
SPA: RelationChildModal.vue, RelationPivotModal.vue
Analog: admin/src/components/relation/RelationPickerModal.vue 1-50:
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { api } from '../../api/client'
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
import { message, t } from '../../app/i18n'
import { showToast } from '../../state/useToasts'
const props = defineProps<{ open: boolean; source: ControllerParams; recordId: number; relation: string; schema: RelationSchema }>()
const emit = defineEmits<{ 'update:open': [open: boolean]; linked: [count: number]; closed: [] }>()
let generation = 0 // stale-response guard
Wire into RelationManager.vue; remove create-screen drop in FormView.vue:71-77 only for deferrable relations. Rebuild modules/boardwalk/dist (scripts/check-admin-dist.sh).
Shared Patterns
- Transactions:
lagoon.Transaction→withTx→loadRecord(FOR UPDATE, appliespact.FormExtendQuery; crud.go 441-461). Apply to all child/file/commit mutations. - After-commit side effects:
lagoon.AfterCommit(ctx, db, fn)(transaction.go:103) +attach.DeleteKeys. Apply to every blob delete. - Error envelope:
recordNotFound{}→ 404,relationInvalid(field,msg)→ 422,lifecycleFailure(cc, err)for hook errors; mapped bywriteCRUDError(crud.go 393-410). Foreign child = 404, never 403. - Write routes:
requireAjax(...)+constrain*after eachg.Post/Put/Delete(http.go 248-259). - Body caps:
http.MaxBytesReader(cabana raw routes have no surf limit —surf/bodylimit.go:29-33). - Boot-time contract checks: type-assert capability, fail
cabana.Activatewith descriptive error (relation.govalidateRelationContract338-394). - Docs rule: every exported API/config/CLI change updates module
README.mdanddocs/(docs/backend/relation-manager.md) in the same change;go test ./cmd/summer -run TestDocsTree.
No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
modules/lagoon/attach/guard.go |
utility | transform | Image guard exists only in an application plugin; port per RESEARCH Pitfall 5 |
admin/src/app/sessionKey.ts, dateFormat.ts |
utility | transform | No crypto/date helpers in SPA; use RESEARCH (crypto.getRandomValues base64url; PHP-format token table, Pitfall 13). Shape like admin/src/app/listQuery.ts (pure exported functions) |
Metadata
Analog search scope: modules/lagoon, modules/lagoon/attach, modules/cabana, modules/conga, modules/pact, modules/surf, modules/wristband, admin/src/components, admin/src/app Files scanned: ~30 Pattern extraction date: 2026-10-02