feat(npm): packument-merge core (S31 Step 13, part 2/3)
Stacked MRs (review/merge bottom-up)
- feat(npm): dist-tags merge, pass-1 validation (... (!2931 - merged) • David Fernandez • 19.5
- feat(npm): packument-merge core (S31 Step 13, p... (!2932 - merged) • David Fernandez • 19.5
👈 - test(npm): merge edge cases and property test (... (!2933 - merged) • David Fernandez • 19.5
Split into 3 stacked MRs to keep each within the review size limit (≈800 ideal). Each part targets the previous (part 1 → main), reviewed and merged bottom-up.
Part 2 of 3 of docs/plans/2026-08-21-s31-npm-virtual.md — Step 13: metadata merge and serve surface
Continues Part 1 of this stack — see part 1 for the full step scope and the parts this step's own plan text is split into.
This part's scope:
MergePackuments, the packument-merge core: the streaming dispatcher that mergesversions(whole-object, lowest-position-wins),dist-tags(union),time(per-key union,modifiedmax /createdmin, RFC3339Nano tie-break), and the descriptive top-level fields (whole set, lowest contributing position) into one document, streamed to anio.Writerrather than buffered.- The per-version integrity/shasum digest-pair tracking that feeds the
integrity_divergence_totalcounter added in part 1. - One happy-path test per merge sub-feature, plus the streaming-not-buffering and byte-identical-rerun determinism guarantees.
Added while rebasing onto part 1's review fixes: part 1's review renamed readStringOrSkip→readStringValue, making a non-string pass-2 dist-tags value fatal instead of coercing it to "" — a generalization of the atomic-per-position-omission pattern the reviewer flagged as open, not spec-mandated (see part 1's discussion). This part's time.modified/time.created handling used the same helper under its old name; rather than reintroduce a separate coercing helper, it's now unified onto readStringValue too, so a non-string time.modified/time.created in pass 2 is fatal the same way. Pass 1 already rejects a non-string time member (validateTimeMembers, merged in part 1), so this only matters if a contributor's Open breaks its same-bytes-every-call contract — pinned by TestMergePackuments_Pass2NonStringTimeValueIsFatal, mirroring part 1's dist-tags test. The sentinel error is renamed errMergeTagValueNotString → errMergeValueNotString to match.
Part 3 adds the remaining edge-case, integrity-divergence, and property tests over this part's code.
Spec: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/specs/S31-npm-virtual.md, "Cross-upstream packument merge" and "Metrics". Plan: https://gitlab.com/gitlab-org/ops/artifact-registry/-/blob/main/docs/plans/2026-08-21-s31-npm-virtual.md, Step 13.
Related to #894 (closed)