feat(managementapi): serve the npm bulk-delete route arms

Why

The three package-family bulk-delete routes have been dispatch shells since the write-route scaffold landed: the contract declares them, the selector decode and the enqueue seam are merged, and the registered npm bulk worker already applies packages and files batches, but every npm request still answers the placeholder 501. This MR fills the npm arms, so a caller can empty npm collections through the API: the selector decodes ahead of any read, the repository resolves through the same ladder the single-artifact deletes run (format family, stored-format comparison, hosted-kind narrowing), and each accepted request records one mgmtapi:bulk-delete-npm job. The Maven arms keep their placeholder for the sibling step's own MR.

Depends on (merge and deploy order): the worker's versions arm rides chore(managementapi): apply npm bulk deletes fo... (!1623 - merged) • Hayley Swimelar • 19.4, which merges and deploys ahead of this MR, the worker-first rule docs/dev/background-jobs.md fixes: a rolling deploy leaves old pods fetching from the same queue, and a versions job fetched by a pod without the worker arm errors and retries on River's default budget (roughly 20 days) while the caller already holds a 202. A merge request dependency recorded on this MR makes the merge half mechanical. Within one release the doc's own rolling-window caveat applies: old pods reject new versions jobs until the rollout completes, and River's backoff re-delivers them to new pods afterward. Packages and files batches apply end to end today.

The versions end to end lands in this MR, not in the worker MR, whose change set carries no route: once the dependency merges, this branch rebases and extends TestIntegration_NpmBulkRoutes_VersionsEnqueueShape into a worker walk on the packages/files walk template, before this MR merges. Until that rebase the versions walk stops at the enqueue seam and asserts the recorded job's shape.

What (non-obvious)

  • The shells promised the selector decode ahead of the format dispatch. It lands at the top of each served arm instead, through the route's one shared spec value: a placeholder arm must keep answering 501 without reading the body (the contract sweep enforces it), so a dispatch-level read cannot exist while any arm stands. The shell comments record the settled position.
  • The routes are enqueue-only. No artifact row is read, no event is emitted, and a well-formed parent segment or entry that resolves to nothing is the worker-side no-op the 202 promises, because the worker re-resolves the whole chain at application time. Parent path segments are parsed for canonical-UUID shape only.
  • One job always suffices: a cap-sized subset of 1,000 UUID entries marshals well under the 64 KiB payload cap (pinned by TestBulkDeleteNpmArgs_FullSubsetBatchFitsOneJob), so the npm selectors need none of the container tag chunking.
  • managementapi.Deps gains the one BulkEnqueuer seam all four bulk route families share, wired to the merged composition-root adapter over the River client holder. The composition walk proves the wired seam is live: with an unpublished holder every bulk POST answers 503 while all unit tests stay green, which is exactly the failure the walk exists to catch (verified by mutation during development).

Diff size: ~1,430 insertions, of which ~1,170 are tests (the unit matrix, the per-route walks, and the composition walk). Production code is ~260 lines across the three arm files, handler.go, and the wiring. Splitting tests from the arms they pin would not aid review.

Test plan

  • go test ./internal/managementapi/ ./cmd/artifact-registry/ (unit matrix: selector rejections that read no store, the repository 404 ladder, path-ID 404s, 202 payload shape validated against the worker's boundary check, the 503/500 enqueue split, tracker silence, payload fit).
  • go test -tags=integration for both packages: packages-subset and files-delete_all walks run through the registered worker to row-level assertions, the versions walk asserts enqueue and job shape, and TestIntegration_WireManagementAPI_NpmBulkRouteReachesTheWorker drives the production wiring from POST to a completed job and a 404 re-read.
  • All green locally, including golangci-lint with --build-tags=integration.

This MR adds no docs/testing/ scenario: the delete-family catalog pass lands once as the management delete arms complete, tracked in E2e lifecycle catalogs: one delete-family pass ... (#638) • Unassigned.

Context for LLM reviewers

Design rationale and rejected alternatives:

  • Decode-in-arms vs decode-ahead-of-dispatch: the shell comments planned the decode ahead of the dispatch. That placement would make the still-unserved Maven arms answer 400 on a malformed body instead of their placeholder 501, failing the contract sweep's pending entries, which only the Maven step may drain. The decode therefore sits at the top of each served arm over the shared bulkSelectorSpec value, which keeps the selector rules stated once. The spec's ordering rule (body validated before path segments and artifact work) is pinned mechanically: every rejection row asserts the repository reader recorded zero calls.
  • The hosted-kind narrowing reuses resolvePackageRepositoryID (the hosted child-row probe) for parity with the merged Maven single-delete arm, and its returned child id is deliberately discarded: BulkDeleteNpmArgs.RepositoryID carries the shared repositories row id, which is what the worker re-resolves the chain by.
  • The arms do not check that the versions route's package or the files route's version exists. The spec makes missing or foreign targets worker-side no-ops (retry safety and existence hiding), and the worker re-resolves parents live. Adding a route-side existence read would change 202s into 404s the contract does not declare.
  • npmBulkEntryIDs parses selector output with uuid.Parse and surfaces the impossible failure eagerly in npmBulkResolve, ahead of any store read, as the logged contract-violation 500 the package's other impossible-value arms use. The selector admits only canonical non-zero UUIDs, so the arm is unreachable through any decoded body and exists to keep a broken invariant loud without a panic in the request goroutine.
  • npmBulkParentScopedArm exists because the versions and files arms are token-identical otherwise and dupl fires at file scope, where a function-level suppression cannot reach.

Non-goals a reviewer might raise:

  • The Maven arms and their pending-entry drains belong to the sibling Maven bulk-routes step, being built concurrently on its own branch. Overlap in these files is expected and arm-local.
  • The cancelled-request early return in logAndWriteInternalError (resolve.go) predates this MR and is deliberately untouched.
  • No OpenAPI or Bruno change: the contract for these operations, including the family's own 202 and 503 components, is already merged, and this MR only drains the three npm pending entries from the route sweep.
  • The plan file is not edited here: the Status table for this wave is maintained by its own table-owner MR.

Related to #313 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading