
Act on the records people select
People often need to work on a set of records together: delete completed tasks, assign several items or apply the same change to a selection.
Wildo can expose a bulk companion to an operation. The selected records travel with the request as its target, so the action can work on that set instead of asking someone to repeat the same interaction for every row.
You choose which actions make sense in bulk and who may use them. The selection and the change remain separate parts of the operation.
Example — Remove the completed work you selected
A team member selects the completed tasks in a list and deletes them together. Tasks outside that selection remain in the list, including work that is still in progress.
For engineers
Enable bulk behavior on the operation
haveBulkOperation belongs on a supported API variant. The factory derives a companion operation and its collection-level route from that declaration.
Wonder Todos enables the delete companion on its API variant in todos.resources-config.ts. These are the relevant lines; MCP exposure, notifications and other operation settings are omitted:
[CoreResourceOperation.DELETE]: {
variants: [
{
variantType: ResourceOperationVariantType.API_CALL,
isDefault: true,
haveBulkOperation: true,
roles: [CORE_ORG_ROLES.ORG_MEMBER],
riskLevel: ResourceOperationRiskLevel.LOW,
// Other variant settings are omitted.
},
],
},
The same entry declares who may call the operation and how it is classified. The generated bulk companion uses the variant configuration rather than requiring an unrelated second action declaration. Read and list are not mutation targets for this option.
Keep selected IDs out of the record payload
The shared selector name is _ids. A bulk request carries a non-empty list of record identifiers under that field, together with the input required by the action. The DTO builder includes the selector in the bulk request contract.
At service dispatch, Wildo resolves the selection into the target and strips it from the data passed onward. An already supplied programmatic target takes precedence. The selector therefore cannot become an ordinary persisted field or redirect a target the caller has already chosen.
Send a selection and read what was deleted
The application’s bulk-operation-selector.e2e.ts exercises the standard path on organization API keys. The following request uses that route with illustrative organization and key IDs; the API mount prefix is omitted:
DELETE /organizations/example-org/organization-api-keys/bulk
Content-Type: application/json
{
"_ids": ["key-a", "key-b"]
}
For a successful deletion of both keys, the standard serialized response is:
{
"deletedIds": ["key-a", "key-b"],
"deletedCount": 2
}
The service’s raw delete result is converted to this response envelope. Even a one-ID bulk request keeps deletedIds and deletedCount; it does not suddenly return the single-delete response shape. Use the returned IDs as the result, rather than assuming every requested ID was deleted.
The HTTP scenario also creates a third key and verifies that it survives. Selection means the named records, not every row currently matching the screen’s filter.
| Submitted selection | Meaning and outcome |
|---|---|
_ids: ["key-a", "key-b"] | Targets those IDs within the caller’s permitted scope. The result names the deleted IDs. |
_ids: [] | Invalid request; it must not mean “all records.” |
ids: ["key-a"] | Wrong field name; it does not satisfy the required _ids contract. |
| An ID belonging to another organization | Naming it does not make it reachable through the current organization’s route. |
| A record disappears before its pre-operation snapshot is loaded | Missing reads are excluded from the snapshot set. This is not a per-ID success/failure report. |
The multi-record loader resolves matching snapshots and removes missing reads before hooks receive them. It does not manufacture an error entry for every unavailable ID. An actual read or access error still propagates; do not interpret absence and refusal as the same result, or invent a universal partial-success contract for custom actions.
Understand the failure boundary you are relying on
haveBulkOperation creates an entry point, not a promise that every external effect rolls back together. The service runs pre-delete business hooks before file cleanup and repository deletion; a thrown pre-hook aborts that path before those effects begin.
A concrete storage boundary matters for PostgreSQL records with file-owning children. When file metadata uses a separately committed adapter and service-managed child deletion is absent, the bulk-delete guard refuses before the repository delete. Declaring the intended child lifecycle is necessary; it does not create a distributed transaction between PostgreSQL records and MongoDB file metadata.
Shared same-adapter transactions can include parent deletion and configured lifecycle work. Separately committed file work has a different failure boundary. Choose the action and recovery behavior with that topology in mind, and refresh affected records after a failure instead of assuming a generic bulk flag guarantees rollback of every effect.
Match the action to a selected set
Use bulk behavior where the business rule makes sense for all selected records. An assignment action still needs its assignment rules; a delete still needs the resource’s configured lifecycle. A single selected identifier takes the single-record path, while a multi-record operation has the semantics of its bulk path.
The normal HTTP contract requires a usable selection. For internal callers, use the shared selector helpers and an explicit target rather than spelling an independent convention. Keep the UI’s selected IDs tied to the action it submits, especially when the visible list is filtered or paginated.