feat(npm): dist-tag publish writes (S11 Step 5)

📦 What

Step 5 of the npm local plan, sliced into four stacked MRs. Slice 4 (top) — the dist-tag publish-side write methods.

Store file Write methods
npm_tags.go UpsertNpmTag (insert or rebind, reports inserted), DeleteNpmTag (reports existed)

Per-store argument-guard sentinels (errNpmTag*) match the read-store convention Step 4b adopted, including non-positive NpmPackageID/NpmVersionID guards on the write path (a zero/negative ID otherwise FK-violates opaquely or returns a misleading 404). A bad-but-positive package/version ID that clears the guard but references no row is classified into the shared leak-safe ErrParentNpmPackageMissing / ErrParentNpmVersionMissing sentinels (returned unwrapped so PgError.Detail can't leak the parent id or namespace UUID), on both the INSERT and the rebind UPDATE round-trips - matching InsertNpmVersion / InsertNpmFile / UpsertNpmPackage.

ℹ️ Metadata-cache writes carved out. This slice originally also carried ForceExpireNpmMetadata / UpsertNpmMetadataFile. The Step 4b reorg removed the npm_metadata_files read layer those build on (deferred to a later step), so the metadata writes were pulled out to follow it.

Testing

internal/datastore/npm_write_integration_test.go (integration, testcontainers PostgreSQL): UpsertNpmTag insert / rebind, DeleteNpmTag existing / missing, and the FK leak-safe sentinels (nonexistent package, nonexistent version, and a rebind-path nonexistent version). Adds internal/datastore/npm_tags_test.go (unit): argument-guard tables for both methods (nil ctx / db, zero namespace, non-positive package/version ID, empty name) plus sequencedDB-driven error-path tests - the rebind-race ErrNotFound translation and the INSERT / UPDATE / DELETE error wraps.

Size: ~737 LOC, ~61% test.

🔗 Stack

Step 5 was split into four stacked MRs (each targets the previous; GitLab auto-retargets to main as they merge):

  1. !499 (merged)package_json schema validator — merged
  2. !503 (merged) — package & file writes + shared test harness — merged
  3. !504 (merged) — version writes — merged
  4. !505 (merged) — dist-tag writes — this MR (now targets main)

The metadata-cache writes that originally shared slice 4 were carved out: the Step 4b reorg removed the npm_metadata_files read layer they build on (deferred to a later step), so they follow it there.

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
npm_tags.UpsertNpmTag._Insert Insert unique_npm_tags_ns_id_pkg_id_name (conflict arbiter) 1 / 1 0.01 1.948ms 77 / 3 1
npm_tags.UpsertNpmTag._Rebind Update n/a (Seq Scan at seed scale; see notes) 1 / 1 1.88 2.504ms 8 / 2 1
npm_tags.DeleteNpmTag Delete n/a (Seq Scan at seed scale; see notes) 0 / 0 1.88 0.097ms 2 / 0 1
npm_tags.UpsertNpmTag._Insert

Summary: Plan matches intent. The INSERT routes to a single hash partition on namespace_id (npm_tags_p58) and uses unique_npm_tags_ns_id_pkg_id_name as the ON CONFLICT arbiter, exactly the (namespace_id, npm_package_id, name) conflict target the method declares; 1 tuple inserted, 0 conflicting. The three FK-validation triggers (namespaces, npm_packages, npm_versions) fire as expected for an insert. No anomalies.

Seed shape: namespaces=1, repositories=1, npm_repositories=1, npm_packages=1, npm_versions=1, npm_tags=0

Rendered SQL:

INSERT INTO public.npm_tags (namespace_id, npm_package_id, npm_version_id, name)
VALUES ($1::uuid, $2, $3, $4::text)
ON CONFLICT (namespace_id, npm_package_id, name) DO NOTHING
RETURNING npm_tags.id AS "npm_tags.id";

Bound args: [<seeded namespace uuid>, <seeded npm_package id>, <seeded npm_version id>, 'latest']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on npm_tags  (cost=0.00..0.01 rows=1 width=72) (actual time=0.240..0.241 rows=1 loops=1)
   Conflict Resolution: NOTHING
   Conflict Arbiter Indexes: unique_npm_tags_ns_id_pkg_id_name
   Tuples Inserted: 1
   Conflicting Tuples: 0
   Buffers: shared hit=77 read=3 dirtied=7 written=4
   ->  Result  (cost=0.00..0.01 rows=1 width=72) (actual time=0.027..0.027 rows=1 loops=1)
         Buffers: shared hit=12
 Planning:
   Buffers: shared hit=53
 Planning Time: 0.256 ms
 Trigger for constraint fk_npm_tags_namespace_id_namespaces on npm_tags_p58: time=0.185 calls=1
 Trigger for constraint fk_npm_tags_npm_package_id_npm_packages on npm_tags_p58: time=0.496 calls=1
 Trigger for constraint fk_npm_tags_npm_version_id_npm_versions on npm_tags_p58: time=0.650 calls=1
 Execution Time: 1.948 ms

Timings: planning 0.256ms, execution 1.948ms, total 2.204ms.

npm_tags.UpsertNpmTag._Rebind

Summary: Plan matches the rebind intent: the namespace_id equality prunes to one partition (npm_tags_p38), then the row is located by the three-column WHERE and npm_version_id is updated. The planner chose a Seq Scan over the WHERE-covering unique_npm_tags_ns_id_pkg_id_name index because the partition holds only 50 seeded rows (49 removed by filter), where a scan is cheaper than an index probe. At production cardinality the index is used (confirmed separately: at ~5000 rows in one partition the same predicate switches to an Index Scan on the partition-local unique index). No anomaly.

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

Rendered SQL:

UPDATE public.npm_tags
SET npm_version_id = $1
WHERE ((npm_tags.namespace_id = $2::uuid) AND (npm_tags.npm_package_id = $3)) AND (npm_tags.name = $4::text)
RETURNING npm_tags.id AS "npm_tags.id";

Bound args: [<seeded npm_version id>, <seeded namespace uuid>, <seeded npm_package id>, 'latest']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Update on npm_tags  (cost=0.00..1.88 rows=1 width=18) (actual time=0.039..0.041 rows=1 loops=1)
   Update on npm_tags_p38 npm_tags_1
   Buffers: shared hit=8 read=2
   ->  Seq Scan on npm_tags_p38 npm_tags_1  (cost=0.00..1.88 rows=1 width=18) (actual time=0.002..0.004 rows=1 loops=1)
         Filter: ((namespace_id = 'f647a798-3cbc-461c-b366-9e9cb4a6afc0'::uuid) AND (npm_package_id = '2'::bigint) AND (name = 'latest'::text))
         Rows Removed by Filter: 49
         Buffers: shared hit=1
 Planning:
   Buffers: shared hit=128
 Planning Time: 0.262 ms
 Trigger for constraint fk_npm_tags_namespace_id_namespaces on npm_tags_p38: time=0.013 calls=1
 Trigger for constraint fk_npm_tags_npm_package_id_npm_packages on npm_tags_p38: time=1.087 calls=1
 Trigger for constraint fk_npm_tags_npm_version_id_npm_versions on npm_tags_p38: time=1.282 calls=1
 Execution Time: 2.504 ms

Timings: planning 0.262ms, execution 2.504ms, total 2.766ms.

npm_tags.DeleteNpmTag

Summary: Plan matches the hard-delete intent: namespace_id prunes to a single partition (npm_tags_p32); the row is found by the three-column WHERE and deleted. As with the rebind, a Seq Scan is chosen at the 50-row seed size (49 removed by filter) rather than the WHERE-covering unique_npm_tags_ns_id_pkg_id_name index — a small-table effect, not a missing index; the same predicate uses the partition-local unique index at ~5000 rows. No FK-check triggers (DELETE validates no outbound FKs). No anomaly.

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

Rendered SQL:

DELETE FROM public.npm_tags
WHERE ((npm_tags.namespace_id = $1::uuid) AND (npm_tags.npm_package_id = $2)) AND (npm_tags.name = $3::text);

Bound args: [<seeded namespace uuid>, <seeded npm_package id>, 'latest']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Delete on npm_tags  (cost=0.00..1.88 rows=0 width=0) (actual time=0.008..0.008 rows=0 loops=1)
   Delete on npm_tags_p32 npm_tags_1
   Buffers: shared hit=2
   ->  Seq Scan on npm_tags_p32 npm_tags_1  (cost=0.00..1.88 rows=1 width=10) (actual time=0.003..0.005 rows=1 loops=1)
         Filter: ((namespace_id = 'c0d6d278-c17b-46de-8f45-106f00f2b429'::uuid) AND (npm_package_id = '3'::bigint) AND (name = 'latest'::text))
         Rows Removed by Filter: 49
         Buffers: shared hit=1
 Planning:
   Buffers: shared hit=116
 Planning Time: 0.294 ms
 Execution Time: 0.097 ms

Timings: planning 0.294ms, execution 0.097ms, total 0.391ms.

Query notes:

  • No anomalies. All three statements prune the namespace_id hash key to a single partition (ADR 022). UpsertNpmTag._Insert resolves its ON CONFLICT arbiter to unique_npm_tags_ns_id_pkg_id_name. The Seq Scans on _Rebind and DeleteNpmTag are write-target seed-cardinality artifacts (50 rows/partition per the skill's methodology), not missing indexes: the same (namespace_id, npm_package_id, name) predicate switches to an Index Scan on that partition-local unique index at ~5000 rows (verified).

Related to #122 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading