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

File diff suppressed because it is too large Load Diff

View File

@@ -2374,7 +2374,10 @@ export interface paths {
/** @description Records per page (1-100, default 20) */
per_page?: number;
};
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2382,7 +2385,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2468,7 +2471,10 @@ export interface paths {
/** @description Records per page (1-100, default 20) */
per_page?: number;
};
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2476,7 +2482,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2556,7 +2562,10 @@ export interface paths {
post: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2564,7 +2573,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2653,7 +2662,10 @@ export interface paths {
post: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2661,7 +2673,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2742,7 +2754,10 @@ export interface paths {
get: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2750,7 +2765,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2815,7 +2830,10 @@ export interface paths {
put: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2823,7 +2841,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -2918,7 +2936,12 @@ export interface paths {
post: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
/** @description Child form session key: the save attaches the child files uploaded under it */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -2926,7 +2949,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -3016,7 +3039,10 @@ export interface paths {
get: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -3024,7 +3050,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -3089,7 +3115,12 @@ export interface paths {
put: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
/** @description Child form session key: the save attaches the child files uploaded under it */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -3097,7 +3128,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
@@ -3176,6 +3207,694 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List the files of a related record's fileupload field
* @description The child-form counterpart of the record file list: the files attached to the related record minus the X-Child-Session-Key session's pending removals, plus its pending uploads. The related record is scoped to the owner like the child show route; child 0 needs the create toolbar button and the child key, a saved child the update button or a view form (403 otherwise).
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
/**
* Upload a file to a related record's fileupload field
* @description Stores one multipart file_data part and binds it to the X-Child-Session-Key session; the child's create or update save with the same key attaches it. Limits and errors as on the record upload route. Writes to a saved child need the update toolbar button (403 otherwise).
*/
post: {
parameters: {
query?: never;
header: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -) */
"X-Child-Session-Key": string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/**
* Format: binary
* @description The file
*/
file_data: string;
};
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reorder a related record's files
* @description As the record reorder route: ids must be exactly the field's visible files. attachMany only, otherwise 403.
*/
post: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
};
cookie?: never;
};
/** @description File ids in the new order */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-array_cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Save a related record file's title and description
* @description As the record caption route, for a file of the related record or of the child session. The field must declare useCaption (403 otherwise).
*/
put: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
/** @description Title and description */
requestBody: {
content: {
"application/json": components["schemas"]["cabana.AdminFileCaptionRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileItem"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
post?: never;
/**
* Remove a related record's file
* @description Removing an attached file is deferred to the child's next save with the same X-Child-Session-Key; removing a pending upload deletes it at once.
*/
delete: {
parameters: {
query?: never;
header: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -) */
"X-Child-Session-Key": string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FileMutationResult"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download a related record's protected file
* @description As the record download route, for a protected file of the related record or of the child session, with the same headers.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Thumbnail of a related record's protected image
* @description As the record thumb route, for a protected image of the related record or of the child session.
*/
get: {
parameters: {
query?: never;
header?: {
/** @description Owner form session key; needed when the owner id is 0 */
"X-Session-Key"?: string;
/** @description Child form session key (32-128 characters of A-Z a-z 0-9 _ -); needed for child 0 and for pending uploads */
"X-Child-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;
/** @description Related record id (0 for the child being created) */
child: number;
/** @description fileupload field of the relation's manage form */
field: string;
/** @description File id */
file: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink": {
parameters: {
query?: never;
@@ -3189,7 +3908,10 @@ export interface paths {
post: {
parameters: {
query?: never;
header?: never;
header?: {
/** @description Form session key; with it, owner id 0 is the record being created in that session */
"X-Session-Key"?: string;
};
path: {
/** @description Vendor */
vendor: string;
@@ -3197,7 +3919,7 @@ export interface paths {
plugin: string;
/** @description Controller */
controller: string;
/** @description Owner id */
/** @description Owner id (0 for the record being created) */
id: number;
/** @description Relation name */
name: string;