290 lines
17 KiB
Markdown
290 lines
17 KiB
Markdown
---
|
|
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
|
plan: 03
|
|
subsystem: admin
|
|
tags: [cabana, relation-manager, hasMany, belongsToMany, pivot, deferred-binding, fileupload, openapi]
|
|
|
|
requires:
|
|
- phase: 12.2-01
|
|
provides: deferred_bindings store ops (DeferredBind/Unbind/Bindings/Forget/Slaves, DeferredEnvelope), MorphType, pact Relation{Before,After}{Create,Update,Delete} hooks, PurgeDeferred over Models()
|
|
- phase: 12.2-02
|
|
provides: X-Session-Key, fileScope and the seven file handlers, commitDeferred in CRUDService.save, fileupload/datepicker compile
|
|
provides:
|
|
- RelationContract.Kind / ForeignKey with RelationHasMany and RelationBelongsToMany (empty Kind is belongsToMany)
|
|
- manage.form / view.form / top-level form and pivot.form compiled against the related or pivot model; $/ paths inside the plugin
|
|
- view toolbarButtons create|update|delete|link|unlink as route capabilities
|
|
- child create/show/update/delete and pivot show/update routes, parent-scoped through loadChild (404 on a miss)
|
|
- hasMany link/unlink, pivot values on link through the pivot form whitelist
|
|
- record id 0 relation routes held against X-Session-Key and committed by the record's create save
|
|
- child file routes keyed by X-Child-Session-Key (ChildSessionKeyHeader), committed by the child save
|
|
- RelationSchema kind, deferrable, manageForm, viewForm, pivotForm; FormField.deferrable; 17 relation message keys
|
|
- boot check that a deferrable relation with create has its related model in some plugin's Models()
|
|
affects: [12.2-04 admin SPA (child, pivot and create-screen UI on these routes), 12.2-05 unit and security tests]
|
|
|
|
actuals:
|
|
tokens: 82200
|
|
tasks: 3
|
|
commits: 3
|
|
plan_head_before: 0b4ef9c311036309cd9fdaf5fdd1bc84f79a1297
|
|
plan_head_after: fe9e8baaf14a21f474db405343230071b6b8bdaa
|
|
|
|
tech-stack:
|
|
added: []
|
|
patterns:
|
|
- "A relation form is compiled as a CompiledController of its own (relationModelController around the related or pivot model), so Fill, Validate, file and date pipelines are reused unchanged"
|
|
- "Every child, pivot and child-file query carries the parent predicate (FK, pivot EXISTS, or the session's bound slaves); a miss is recordNotFound"
|
|
- "File handlers run on a fileRoute (fields, key header, scope), shared by record and relation child file routes"
|
|
- "Link and unlink go through linkRelated/unlinkRelated, used by the routes and by the deferred commit"
|
|
|
|
key-files:
|
|
created:
|
|
- modules/cabana/relation_form.go
|
|
- modules/cabana/relation_child.go
|
|
- modules/cabana/relation_child_smoke_test.go
|
|
- modules/cabana/example_relation_test.go
|
|
modified:
|
|
- modules/cabana/relation.go
|
|
- modules/cabana/deferred.go
|
|
- modules/cabana/field_file.go
|
|
- modules/cabana/http.go
|
|
- modules/cabana/registry.go
|
|
- modules/cabana/schema.go
|
|
- modules/cabana/form_schema.go
|
|
- modules/cabana/schema_types.go
|
|
- modules/cabana/messages.go
|
|
- modules/cabana/admin_openapi.go
|
|
- modules/cabana/security_coverage_test.go
|
|
- modules/cabana/openapi_conformance_test.go
|
|
- modules/cabana/phase10_csrf_test.go
|
|
- modules/cabana/phase10_coverage_test.go
|
|
- modules/cabana/datepicker_smoke_test.go
|
|
- modules/phrasebook/backend/lang/en/lang.yaml
|
|
- modules/phrasebook/backend/lang/pl/lang.yaml
|
|
- admin/openapi/admin.json
|
|
- admin/src/api/schema.d.ts
|
|
- admin/tests/fixtures/widgets.relation-schema.json
|
|
- modules/cabana/README.md
|
|
- docs/backend/relation-manager.md
|
|
|
|
key-decisions:
|
|
- "12.2-03: a relation form is compiled into its own CompiledController (relationModelController), so the child save reuses projectOperation, fillAllowed, mergedRules, dateBoundDetails and commitDeferred"
|
|
- "12.2-03: a hasMany child's ForeignKey is set from the scoped parent before Fill and Validate, so a model rule on the key passes; a body naming the key is dropped"
|
|
- "12.2-03: pivot values are filled through the pivot form's writable fields only (the pivot model needs no Fillable), and only the rules of those fields are checked"
|
|
- "12.2-03: unknown pivot keys are a 422 on PUT .../pivot/{child} as on link, not silently dropped"
|
|
- "12.2-03: at commit a bind of a child the session created attaches directly (hasMany: key set if still NULL; belongsToMany: pivot row with RelationBeforeLink); a bind of an existing record re-runs the full link eligibility"
|
|
- "12.2-03: unlink on an unsaved parent only cancels ids bound in the session; no stray unbind rows are written"
|
|
- "12.2-03: child file routes on a saved child answer 403 without update (writes) or without update or a view form (reads); child 0 without create is 404, as a record's id 0"
|
|
- "12.2-03: the Models() boot check matches the related model by Go type, or by morph type when a database is published"
|
|
- "12.2-03: the pending-created exclusion matches pivot_data LIKE '{\"created\":true%', the envelope's encoded prefix, so a malformed pivot_data row cannot break candidate queries"
|
|
|
|
patterns-established:
|
|
- "relationParent: a saved record (id) or the unsaved one (DeferredKey + related morph); relationQuery and loadChild branch on it"
|
|
- "Route capability: relationAllowed(cr predicate) writes 404 for an unknown relation and 403 for an undeclared capability before any SQL"
|
|
|
|
requirements-completed: [SC-3, SC-4]
|
|
|
|
coverage:
|
|
- id: D1
|
|
description: "A hasMany child created under gadget A carries A's id, lists under A only, and a body naming gadget_id cannot move it"
|
|
requirement: SC-3
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeCreate"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D2
|
|
description: "Show, update, delete and pivot routes answer 404 for a child of another parent and for a FormExtendQuery-hidden parent; a delete naming a foreign child deletes nothing; hasMany link/unlink/delete"
|
|
requirement: SC-3
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeScope"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D3
|
|
description: "Link with a pivot note stores it, RelationBeforeLink still stamps its hook column, pivot keys outside the form and multi-id pivot links are 422"
|
|
requirement: SC-3
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokePivot"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D4
|
|
description: "On id 0 a created part and a member linked with a pivot note are attached by the gadget's create save; an unlinked pending part is deleted; no binding is left"
|
|
requirement: SC-4
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeDeferredCreate"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D5
|
|
description: "A deferred link the saved gadget excludes answers 422 on the relation-manager field, nothing is created, the binding stays"
|
|
requirement: SC-4
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeDeferredRollback"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D6
|
|
description: "An image uploaded to child 0 with X-Child-Session-Key is attached to the part its create makes"
|
|
requirement: SC-4
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokeChildFile"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D7
|
|
description: "Boot stops when a deferrable relation with create has no Models() entry for its related model"
|
|
requirement: SC-4
|
|
verification:
|
|
- kind: unit
|
|
ref: "modules/cabana/relation_child_smoke_test.go#TestRelationChildSmokePurgeModels"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D8
|
|
description: "All 13 new routes inventoried, guarded, CSRF-walked, in admin.json and called once by the conformance suite; SPA types, fixture and dist in sync"
|
|
verification:
|
|
- kind: integration
|
|
ref: "modules/cabana#TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10CSRF|TestPhase10Coverage|TestPhase10OpenAPIConformance"
|
|
status: pass
|
|
- kind: other
|
|
ref: "scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && npm --prefix admin test"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D9
|
|
description: "Existing belongsToMany contracts unchanged: fonoteka.go builds, vets and passes its admin tests with no change"
|
|
verification:
|
|
- kind: integration
|
|
ref: "go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run Admin"
|
|
status: pass
|
|
human_judgment: false
|
|
- id: D10
|
|
description: "Docs and README name only existing identifiers and commands"
|
|
verification:
|
|
- kind: other
|
|
ref: "go test ./cmd/summer -run TestDocsTree && go run ./cmd/summer docs:build --check"
|
|
status: pass
|
|
human_judgment: false
|
|
|
|
duration: 45min
|
|
completed: 2026-10-02
|
|
status: complete
|
|
---
|
|
|
|
# Phase 12.2 Plan 03: Relation child CRUD, pivot forms and deferral Summary
|
|
|
|
**hasMany relation contracts, WinterCMS manage/view/pivot forms and toolbar buttons, parent-scoped child and pivot routes, and relation work on a record being created held against X-Session-Key and committed in its create transaction, with child-form uploads keyed by X-Child-Session-Key**
|
|
|
|
## Performance
|
|
|
|
- **Duration:** about 45 min
|
|
- **Started:** 2026-10-02T16:23:05Z
|
|
- **Completed:** 2026-10-02T17:08:20Z
|
|
- **Tasks:** 3
|
|
- **Files modified:** 26 (summercms.go only; fonoteka.go untouched)
|
|
|
|
## Accomplishments
|
|
|
|
- `RelationContract` gains `Kind` (empty is belongsToMany) and `ForeignKey`. A hasMany contract with pivot fields, a non-integer `ForeignKey`, or an unknown kind stops boot. Existing contracts compile and behave unchanged, and the fonoteka.go admin tests pass with no change.
|
|
- `manage.form`, `view.form` (falling back to a top-level `form`) and `pivot.form` compile against the related or pivot model, with fail-loud errors that name the plugin, controller and file. `$/<vendor>/<plugin>/...` resolves only inside the calling plugin. `relation`, `relation-manager`, `widget` and `partial` fields, and a field named like the hasMany key, stop boot. `pivot[x]` names compile to `x`, and server-owned pivot columns are refused.
|
|
- The view panel's `toolbarButtons` accept `create|update|delete|link|unlink`, and each button is the capability of its routes. create and update need a manage form, and unlink on a non-nullable hasMany stops boot.
|
|
- New routes: child create, show, update and delete, pivot show and update, and the seven child file routes. Every child is found with one query that carries the parent predicate, so a foreign child or a hidden parent is 404. A delete is all or nothing.
|
|
- hasMany link adopts rows whose key is NULL, and unlink clears the key. Rows that another session created and has not saved yet are never candidates. A link may carry pivot values for one id, filled through the pivot form whitelist before `RelationBeforeLink` stamps its hook columns.
|
|
- On record id 0 (with `X-Session-Key`, a deferrable relation, create declared and the field in the create context), create, link, unlink, delete and pivot edits are held in `deferred_bindings`. The record's create save applies them in id order with the file bindings. An ineligible link answers 422 on the relation-manager field and keeps the bindings.
|
|
- The relation schema carries `kind`, `deferrable` and the localized forms, and the relation-manager field carries `deferrable`. There are 17 new message keys in en and pl. Boot refuses a deferrable create relation whose related model no plugin lists in `Models()`.
|
|
|
|
## Task Commits
|
|
|
|
1. **Task 1: hasMany contracts, relation forms and child create** - `48a5b80` (feat)
|
|
2. **Task 2: parent-scoped child show, update, delete and pivot routes** - `afb05b6` (feat)
|
|
3. **Task 3: deferral on unsaved records and child file routes** - `fe9e8ba` (feat)
|
|
|
|
## Files Created/Modified
|
|
|
|
- `modules/cabana/relation_form.go`: `compileRelationForms`, `compileRelationForm`, `relationModelController`, pivot field normalisation, `checkFormDates`
|
|
- `modules/cabana/relation_child.go`: `relationParent`, `loadParent`, `relationQuery`, `loadChild`, `childFileScope`, the `RelationService` child and pivot methods, and the handlers
|
|
- `modules/cabana/relation.go`: contract kinds and validation, schema fields, hasMany queries, `linkRelated`/`unlinkRelated`/`linkDeferred`/`unlinkDeferred`, `fillPivot`, `excludePendingCreated`
|
|
- `modules/cabana/deferred.go`: `ChildSessionKeyHeader`, `childSessionKeyFrom`, relation bindings in `commitDeferred`, `applyRelationBinding`
|
|
- `modules/cabana/field_file.go`: the `fileRoute` abstraction, the child file handlers and `childFiles`
|
|
- `modules/cabana/registry.go`: `checkDeferredModels`
|
|
- `modules/cabana/http.go`: 13 routes, `relationsFor` (session key), the boot check call
|
|
- `modules/cabana/admin_openapi.go`: 13 doc funcs, `AdminRelationLinkRequest`, and session-key headers on the relation routes
|
|
- Tests: inventories, CSRF counts, conformance fixture (`conformPart` hasMany with protected attachMany images, members pivot form with hook stamp and ExcludedRelatedIDs, `Models()`), 7 smoke tests
|
|
- Docs: `docs/backend/relation-manager.md` (kinds, forms, buttons, child routes, pivot forms, create screen, child files, messages), `modules/cabana/README.md`
|
|
|
|
## Decisions Made
|
|
|
|
See `key-decisions` in the frontmatter.
|
|
|
|
## Deviations from Plan
|
|
|
|
### Auto-fixed Issues
|
|
|
|
**1. [Rule 3 - Blocking] The SPA's typed relation schema fixture failed vue-tsc**
|
|
- **Found during:** Task 1
|
|
- **Issue:** `admin/tests/fixtures/widgets.relation-schema.json` is typed against the generated `RelationSchema`, which now requires `kind`, `deferrable` and the 17 new message keys.
|
|
- **Fix:** Added those keys to the fixture. The SPA typecheck, the vitest suite and `check-admin-dist.sh` pass.
|
|
- **Commit:** 48a5b80
|
|
|
|
**2. [Rule 3 - Blocking] The datepicker smoke fixture copies the gadget form**
|
|
- **Found during:** Task 1
|
|
- **Issue:** `datepickerFields` rebuilds the conform gadget's fields.yaml. With the new `parts` relation in config_relation.yaml, boot failed with "relation parts has no relation-manager field".
|
|
- **Fix:** Added the `parts` manager to that fixture.
|
|
- **Commit:** 48a5b80
|
|
|
|
**3. [Rule 1 - Bug] Doc snippet placement**
|
|
- **Found during:** Task 1
|
|
- **Issue:** `docs/backend/admin-controllers.md` embeds the whole `example_controller_test.go`, so a contract example there would have changed an unrelated page.
|
|
- **Fix:** The hasMany example lives in its own `example_relation_test.go`, referenced with `src=`.
|
|
- **Commit:** 48a5b80
|
|
|
|
**4. [Fixture shape] The child file relation is attachMany, not attachOne**
|
|
- **Found during:** Task 3
|
|
- **Issue:** The plan's fixture names an attachOne `image`. The conformance suite must call the reorder route with 200, and reorder is attachMany only.
|
|
- **Fix:** `conformPart` declares a protected attachMany `images` with `useCaption`. All seven child file routes are exercised on a protected relation.
|
|
- **Commit:** fe9e8ba
|
|
|
|
**5. [Fixture shape] The members manager is visible on create**
|
|
- **Found during:** Task 3
|
|
- **Issue:** The deferred link tests need the members manager in the create context, but the fixture had `context: [update]`.
|
|
- **Fix:** Dropped that context from the conform gadget form. The behaviour change for existing managers is documented.
|
|
- **Commit:** fe9e8ba
|
|
|
|
---
|
|
|
|
**Total deviations:** 5 (2 blocking, 1 bug, 2 fixture shape)
|
|
**Impact on plan:** None adds scope. The shipped API matches the plan's artifacts list.
|
|
|
|
## Issues Encountered
|
|
|
|
- The shell's `rm` is interactive, so one cleanup command waited for input. It was rerun as `/usr/bin/rm -f`. No effect on the code.
|
|
- `gofmt -l` reports six files outside this plan (compass, party, tide, wristband). They were left untouched.
|
|
|
|
## Known Stubs
|
|
|
|
None.
|
|
|
|
## Threat Flags
|
|
|
|
None. Every new endpoint and trust boundary is in the plan's threat register (T-12.2-19 to T-12.2-28).
|
|
|
|
## User Setup Required
|
|
|
|
None. A host application whose deferrable relation declares `create` must list the related model in a plugin's `Models()`, and boot names the relation when it does not.
|
|
|
|
## Next Phase Readiness
|
|
|
|
- Plan 04 (SPA) can read `kind`, `deferrable`, `manageForm`, `viewForm` and `pivotForm` from the relation schema and `deferrable` from the relation-manager field. It can use `/records`, `/records/{child}`, `/delete` and `/pivot/{child}`, send `X-Session-Key` with id 0, and send `X-Child-Session-Key` with the child modal's file calls and its create or update.
|
|
- Plan 05 should cover:
|
|
- another admin's pending children (id 0 with a foreign key is 404),
|
|
- unlink and delete of pending belongsToMany links,
|
|
- pivot edits on id 0,
|
|
- commit of unbinds,
|
|
- an attachOne child field,
|
|
- the 413 on child bodies,
|
|
- concurrency of two saves with one key across files and relations.
|
|
|
|
## Self-Check: PASSED
|
|
|
|
- Created files exist: relation_form.go, relation_child.go, relation_child_smoke_test.go, example_relation_test.go.
|
|
- Commits 48a5b80, afb05b6 and fe9e8ba are on master in summercms.go. fonoteka.go has no changes.
|