docs(12.2-03): complete the relation child CRUD and deferral plan summary

This commit is contained in:
Jakub Zych
2026-10-02 19:09:26 +02:00
parent fe9e8baaf1
commit 6cdfbd49d0

View File

@@ -0,0 +1,289 @@
---
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.