From fc2a397d9b0fa416aff6c98eaef2c010abfb9e37 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 16 Oct 2026 12:47:46 +0200 Subject: [PATCH] feat(18.1-08): detached admin bulk actions run outside the framework transaction - pact.AdminBulkAction.Detached (json:"-"): opt-in; records loaded through the list scope without row locks, no write transaction in the context - cabana BulkAction: detached branch with the same empty, partial-selection, failure and message rules; lockScoped is loadScoped with lock, the locked path unchanged - docs: admin-controllers bulk route list, pact and cabana READMEs --- docs/backend/admin-controllers.md | 1 + modules/cabana/README.md | 2 +- modules/cabana/crud.go | 55 ++++++++++++++++++++++++++++--- modules/pact/README.md | 6 ++-- modules/pact/capabilities.go | 15 +++++++-- 5 files changed, 69 insertions(+), 10 deletions(-) diff --git a/docs/backend/admin-controllers.md b/docs/backend/admin-controllers.md index 645adef..7b004bb 100644 --- a/docs/backend/admin-controllers.md +++ b/docs/backend/admin-controllers.md @@ -444,6 +444,7 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se - `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500. - The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403. - `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`. +- An action with `Detached: true` runs outside the framework transaction. Its records are loaded through the same scope, with the same empty and 409 rules, but without row locks, and `cabana.TxFromContext` reports no transaction. `Run` opens the short transactions it needs (`lagoon.Transaction`). Set it on an action that calls an external service, so that no transaction or row lock is held across the call. What the action saved before a later failure stays saved. Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button. diff --git a/modules/cabana/README.md b/modules/cabana/README.md index d635026..7696f07 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -29,7 +29,7 @@ Use cases: - Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot. - Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file. - Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot. -- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. +- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count. A detached action (`pact.AdminBulkAction.Detached`) opens no transaction: its records are loaded through the same scope and rules without row locks, `TxFromContext` reports none, and `Run` opens its own short transactions, so no transaction or lock is held across an external call. - Invisible columns and filter choices: `invisible: true` in `columns.yaml` keeps a column searchable and sortable on the server while the schema flags it, the SPA does not render it and list rows do not carry it. The choices of a scope filter are served by the controller when it implements `pact.FilterOptions`, else by the model. - Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`. - Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id. diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 7abea91..8488f13 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -345,7 +345,9 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in // matches no scoped row is a successful no-op with Affected 0 and the action // does not run. A mixed present/absent selection conflicts and rolls back. A // name the list does not declare, or the controller does not register, is not -// found. +// found. A Detached action opens no transaction: its records are loaded +// through the same scope and rules without row locks, and Run gets a context +// without a write transaction. func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, name string, in BulkDeleteInput) (BulkActionResult, error) { if s.DB == nil { return BulkActionResult{}, errors.New("cabana: database is not configured") @@ -365,6 +367,10 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam return BulkActionResult{}, err } ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr)) + if action.Detached { + result, err := s.detachedBulkAction(ctx, cc, name, action, ids) + return result, localizeForbidden(ctx, s.tr, err) + } var result BulkActionResult err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error { ctx = withTx(ctx, tx) @@ -398,6 +404,36 @@ func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, nam return result, nil } +// detachedBulkAction is the branch of BulkAction for a Detached action: no +// transaction is opened, the rows are loaded through the list scope without +// row locks, and Run gets the locale context without a write transaction. +// The empty, partial-selection, failure and message rules are those of the +// locked path. +func (s CRUDService) detachedBulkAction(ctx context.Context, cc *CompiledController, name string, action pact.AdminBulkAction, ids []uint) (BulkActionResult, error) { + if err := ctx.Err(); err != nil { + return BulkActionResult{}, lifecycleFailure(cc, err) + } + proto, err := newWritableModel(cc) + if err != nil { + return BulkActionResult{}, err + } + rows, err := loadScoped(ctx, s.DB, cc, proto, ids, false) + if err != nil { + return BulkActionResult{}, err + } + if len(rows) == 0 { + return BulkActionResult{}, nil + } + if len(rows) != len(ids) { + return BulkActionResult{}, partialSelection{} + } + out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows}) + if err != nil { + return BulkActionResult{}, actionFailure(cc, "bulk action", name, err) + } + return BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}, nil +} + // actionFailure classifies the error of a bulk or record action's Run like a // lifecycle hook's: the errors a plugin may answer with pass through, anything // else is logged with the controller and action and becomes the opaque @@ -936,13 +972,22 @@ func normalizeIDs(raw []any) ([]uint, error) { return ids, nil } +// lockScoped loads and row-locks the rows of ids through the list scope on +// the write transaction tx (loadScoped with lock). func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto any, ids []uint) ([]any, error) { + return loadScoped(ctx, tx, cc, proto, ids, true) +} + +// loadScoped loads the rows of ids through the controller's ListExtendQuery +// scope on db, ordered by primary key, with FOR UPDATE row locks when lock +// is set (lockScoped) and without them for a detached bulk action. +func loadScoped(ctx context.Context, db *gorm.DB, cc *CompiledController, proto any, ids []uint, lock bool) ([]any, error) { mt := reflect.TypeOf(proto) if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct { return nil, &CapabilityError{ControllerID: controllerID(cc)} } holder := reflect.New(reflect.SliceOf(mt.Elem())) - q := tx.WithContext(ctx).Model(proto) + q := db.WithContext(ctx).Model(proto) if cc != nil { if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil { if next := ext.ListExtendQuery(ctx, q); next != nil { @@ -955,8 +1000,10 @@ func lockScoped(ctx context.Context, tx *gorm.DB, cc *CompiledController, proto for i, id := range ids { vals[i] = castPK(proto, id) } - err := q.Clauses(clause.Locking{Strength: "UPDATE"}). - Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}). + if lock { + q = q.Clauses(clause.Locking{Strength: "UPDATE"}) + } + err := q.Where(clause.IN{Column: clause.Column{Name: col}, Values: vals}). Order(clause.OrderByColumn{Column: clause.Column{Name: col}, Desc: false}). Find(holder.Interface()).Error if err != nil { diff --git a/modules/pact/README.md b/modules/pact/README.md index d57cb4b..b8f89e2 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -27,7 +27,7 @@ Use cases: - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. - Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token. - Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. -- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders). +- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders). - Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices. - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree. @@ -129,8 +129,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, the fill snapshot, and the widget's raw JSON payload (`Payload`, at most 64 KiB, nil when absent, never set for a toolbar action, not inspected by the framework). | | `pact.AdminActionResult` | What an action returns: a message for the toast, the fill write-back values, and an optional `Data` value passed through to the widget as `data` (any JSON up to 256 KiB encoded, not filtered like fill, omitted when nil). | | `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. | -| `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions and the Go `Run` function. | -| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. | +| `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions, the Go `Run` function and `Detached` (run outside the framework transaction, records loaded without row locks, for actions that call an external service). | +| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope (without row locks for a `Detached` action). | | `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. | | `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. | | `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the `Danger` flag (the admin confirms it in the danger variant), the optional `Applies` rule and the Go `Run` function. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index fcc4458..d4a4d0d 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -281,18 +281,29 @@ type HasAdminActions interface { // keys or literal text: Label is the menu item, Confirm the confirmation // question, and an empty Confirm means the framework's default confirm text. // Permissions are checked in addition to the controller's -// RequiredPermissions. +// RequiredPermissions. Detached opts an action out of the framework +// transaction (see the field). type AdminBulkAction struct { Name string Label string Confirm string Permissions []string Run func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) `json:"-"` + // Detached runs Run outside the framework transaction. The selected + // records are loaded through the same ListExtendQuery scope, with the + // same empty and partial-selection rules, but without row locks, and + // the context carries no write transaction (cabana.TxFromContext + // reports none); Run opens the short transactions it needs. An action + // that calls an external service sets it, so that no transaction or row + // lock is held across the call. What such an action saved before a + // later failure stays saved. + Detached bool `json:"-"` } // AdminBulkActionInput is what the framework hands an AdminBulkAction. // Records are pointers to the controller's model, loaded and row-locked -// through the controller's ListExtendQuery scope and ordered by primary key. +// through the controller's ListExtendQuery scope and ordered by primary key +// (loaded without row locks for a Detached action). // A bulk action never receives ids, so an id list can never become an // unscoped lookup. type AdminBulkActionInput struct {