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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user