feat(npm): dispatcher + prerequisites for real handlers (S11 Step 26)

Part 1 of 2 of the npm hosted planStep 26: wire the format route table to the real handlers.

🗂️ Stacked MRs

Split into 2 stacked MRs to reduce review load. Each part targets the previous one (part 1 → main); review and merge bottom-up. Both are test-heavy — reviewable LoC is ~1.6k (part 1) / ~1.1k (part 2).

📦 What this part adds

The npm dispatcher's real-handler constructor and the three prerequisites the composition root (part 2) consumes, so that MR is a pure wiring change. Production still serves the interim 501 — the base NewDispatchHandler stays mounted; the real handlers are exercised here only through spy-based dispatch tests.

  • NewDispatchHandlerWithHandlers + npmRouteHandlers + buildMux — the route table injecting every per-action handler, with the shared PUT /{package_name} route split by body shape. sniffPublishEnvelope peeks the top-level _attachments key (bounded to maxEnvelopeSniffBytes, never buffering the tarball) and reconstructs the full body; the deprecate branch re-caps r.Body at that bound since deprecate decodes r.Body directly (publish re-wraps via its own MaxBytesReader). NewHandler is renamed NewDispatchHandler.
  • server.SetUploadBody — the setter symmetric with OriginalBody, so the split can re-publish the reconstructed body without truncation.
  • NpmVersionStore.BumpLastDownloadedAt (and its NpmPackageStore twin) — the tarball-download last_downloaded_at bump primitive.
  • public_registry_url — an optional absolute-URL NpmConfig field (proto, loader, validation).

Test coverage: spy-based per-route wired/unwired dispatch, the publish/deprecate body-shape split, the sniff memory bound and byte-exact reconstruction past the cap, config round-trip and validation, and datastore bump happy-path, soft-delete no-op, and argument guards.

Database Review Evidence

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 container (matching GL_PG_CURR_VERSION from .gitlab-ci-other-versions.yml), with synthesized seed data rolled back per query and the container torn down at the end of the run. Numbers reflect moderate cardinality and do not capture production-scale effects. See Database review evidence for seed sizing, methodology, and the anomalies the skill flags. Expand each row's details for the seed shape, rendered SQL, bound args, and raw plan.

Method Plan node Index Rows (plan / actual) Cost Time Buffers (hit / read) Partitions
datastore.NpmPackageStore.BumpLastDownloadedAt Update → Seq Scan n/a 1 / 1 0.00..1.75 1.053ms 33 / 0 1
datastore.NpmVersionStore.BumpLastDownloadedAt Update → Seq Scan n/a 1 / 1 0.00..1.75 0.641ms 29 / 0 1
datastore.NpmPackageStore.BumpLastDownloadedAt

Summary: Plan matches the method's intent — a single-row Update targeting one namespace partition (npm_packages_p27) via Seq Scan with a (soft_deleted_at IS NULL, namespace_id, id) filter; 49 of 50 partition rows removed, 1 updated, namespace_id pruning to one of 64 partitions. Seq Scan (rather than pk_npm_packages) is cost-optimal at 50 partition rows and mirrors the decrement UPDATEs in !778 (merged); at production cardinality the planner switches to the PK index. The 1.053ms execution is dominated by a one-time FK-check-trigger plan-cache warm-up (fk_npm_packages_npm_repository_id_npm_repositories: time=0.849, first invocation in the session). No anomalies.

Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=50

Rendered SQL:

UPDATE public.npm_packages
SET last_downloaded_at = NOW()
WHERE ((npm_packages.namespace_id = $1::uuid) AND (npm_packages.id = $2::uuid)) AND (npm_packages.soft_deleted_at IS NULL);

Bound args: [<namespace-uuid>, <npm-package-uuid>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Update on npm_packages  (cost=0.00..1.75 rows=0 width=0) (actual time=0.089..0.089 rows=0 loops=1)
  Update on npm_packages_p27 npm_packages_1
  Buffers: shared hit=33
  ->  Seq Scan on npm_packages_p27 npm_packages_1  (cost=0.00..1.75 rows=1 width=18) (actual time=0.002..0.004 rows=1 loops=1)
        Filter: ((soft_deleted_at IS NULL) AND (namespace_id = '8a22fa59-bba7-4b9d-9339-60b7dbd2c410'::uuid) AND (id = 'b69a089c-8d0a-4f8e-854d-946e4e214579'::uuid))
        Rows Removed by Filter: 49
        Buffers: shared hit=1
Planning:
  Buffers: shared hit=59
Planning Time: 0.195 ms
Trigger for constraint fk_npm_packages_namespace_id_namespaces on npm_packages_p27: time=0.026 calls=1
Trigger for constraint fk_npm_packages_npm_repository_id_npm_repositories on npm_packages_p27: time=0.849 calls=1
Execution Time: 1.053 ms

Timings: planning 0.195ms, execution 1.053ms, total 1.248ms.

datastore.NpmVersionStore.BumpLastDownloadedAt

Summary: Identical plan shape to the package-side twin — a single-row Update on the pruned npm_versions_p55 partition via Seq Scan with the (soft_deleted_at IS NULL, namespace_id, id) filter; 49 of 50 rows removed, 1 updated. Seq Scan is cost-optimal at 50 partition rows and flips to the PK index at production cardinality. Execution 0.641ms, again dominated by the one-time FK-check-trigger warm-up (fk_npm_versions_npm_package_id_npm_packages: time=0.458). No anomalies.

Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=50

Rendered SQL:

UPDATE public.npm_versions
SET last_downloaded_at = NOW()
WHERE ((npm_versions.namespace_id = $1::uuid) AND (npm_versions.id = $2::uuid)) AND (npm_versions.soft_deleted_at IS NULL);

Bound args: [<namespace-uuid>, <npm-version-uuid>]

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

Update on npm_versions  (cost=0.00..1.75 rows=0 width=0) (actual time=0.073..0.073 rows=0 loops=1)
  Update on npm_versions_p55 npm_versions_1
  Buffers: shared hit=29
  ->  Seq Scan on npm_versions_p55 npm_versions_1  (cost=0.00..1.75 rows=1 width=18) (actual time=0.003..0.005 rows=1 loops=1)
        Filter: ((soft_deleted_at IS NULL) AND (namespace_id = 'e5566c14-d379-40ff-86bb-2c6d333998da'::uuid) AND (id = 'fedb5265-8c5b-492d-921b-1231604ee27d'::uuid))
        Rows Removed by Filter: 49
        Buffers: shared hit=1
Planning:
  Buffers: shared hit=279
Planning Time: 0.512 ms
Trigger for constraint fk_npm_versions_namespace_id_namespaces on npm_versions_p55: time=0.016 calls=1
Trigger for constraint fk_npm_versions_npm_package_id_npm_packages on npm_versions_p55: time=0.458 calls=1
Execution Time: 0.641 ms

Timings: planning 0.512ms, execution 0.641ms, total 1.153ms.

Query notes:

  • Both bumps are single-row PK-keyed UPDATEs scoped to one namespace partition (SET last_downloaded_at = NOW()); the Seq Scan is the planner's cost-optimal choice at 50 seeded partition rows and switches to pk_npm_packages / pk_npm_versions at production cardinality — no index gap.
  • Execution times include a one-time FK-check-trigger plan-cache warm-up on first invocation in the session (time=0.849 package / time=0.458 version); steady-state cost is the sub-0.01ms Seq Scan → Update on the pruned partition.

📚 References

Related to #238 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading