feat(npm): publish streaming/CAS staging (S11 Step 15, 2/3)
📦 What
S11 Step 15 (publish handler - stream + coherence + CAS) ships as a three-MR stack, in dependency order:
- !695 (merged) - streampub CAS sink + attachment gate (library seam).
- !696 (merged) - publish handler streaming/CAS staging (Spec Step 3). <- this MR
- !697 (merged) - publish Step 4 coherence check (shasum / integrity / manifest).
Targets !695 (merged) (1/3); retarget to main once it merges. Slice 3/3 (coherence) builds on this.
Slice 2 - the handler's streaming/CAS stage (Spec publish-flow Step 3) on top of the streampub gate. With a blob store wired, ServeHTTP dispatches to stagePublish, which drives streampub.Process through an attachment gate:
- The gate runs the version-exists / quota pre-check once the envelope identity is parsed and before any tarball bytes stream (spec Step 2), opening the upload session lazily only on admission - so a duplicate-version or over-quota publish opens no session and stages nothing (plan Step 14 acceptance). A non-canonical envelope (
_attachmentsbeforename/versions) is rejected with 400publish_envelope_invalidbefore any byte is staged. - On admission the decoded tarball streams to the session as the CAS sink and the session commits (
Commit(nil): the server-computed SHA-256 is the authority). A deferredSession.Cancelaborts the session on every non-commit path, including the after-session-open panic. - A CAS-sink write error surfaces as a logged 500 for now; the
storage_upload_failed(502) code is added but its mapping stays deferred to S07 fault-injection (AC 55).
Interim success returns 200. The Step 4 coherence check lands in !697 (merged) and the npm rows + 201 response in Step 16.
✅ Spec coverage
| Behaviour | Test |
|---|---|
| AC 6 - success path: no CAS GET for coherence + goroutine baseline restored | TestPublishStreamIntegration_SuccessPath_NoCASGet_NoGoroutineLeak |
| AC 7 - malformed gzip/tar -> 422, session aborted, no blob row committed | TestPublishStreamIntegration_MalformedTarball |
AC 8 - decoded tarball > npm.max_tarball_size -> 422 tarball_size_invalid |
TestPublishStreamIntegration_TarballTooLarge |
| AC 48 - panic after session open -> 500, session aborted, goroutine baseline | TestPublishStreamIntegration_PanicAfterSessionOpen |
| Pre-check before stream: version-exists opens no session, stages nothing | TestPublishStreamIntegration_VersionExists_NoSessionOpened |
| Non-canonical envelope (_attachments before name/versions) -> 400, no session | TestPublishStreamIntegration_NonCanonicalEnvelope |
| Storage failures (NewSession / Write / Commit) -> 500 (+ cancel on write) | ..._NewSessionError / ..._SessionWriteError / ..._CommitError |
AC 55 - storage transient on commit -> 502 storage_upload_failed |
Deferred to S07 fault-injection (code added, mapping not yet wired). |
| AC 49/50/51 - coherence-sink goroutine lifecycle | streampub-owned (streampub/lifecycle_test.go, Step 13). |
⚠️ Known gap - route not yet mounted
The publish route still serves the 501 Not Implemented placeholder (internal/format/npm/handler.go); the integration tests construct the handler directly with an injected Resolution rather than routing through the mux. The placeholder -> real-handler swap is an unassigned plan-level gap closed no later than Step 23 (npm-CLI conformance), via a plan amendment, not bolted onto this stack. Deliberately out of scope - while unmounted, the interim 200 (no coherence yet; see !697 (merged)) is unreachable by clients.
🧪 Testing
go test ./internal/format/npm/...- passgolangci-lint run ./internal/format/npm/...- 0 issuesgo vet -tags integration ./internal/format/npm/...- clean- Integration tests (
//go:build integration) drive the handler against the S06PgBlobStoreover testcontainers; run in the merged-results pipeline.
Related to #122 (closed)