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 (_attachments before name/versions) is rejected with 400 publish_envelope_invalid before 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 deferred Session.Cancel aborts 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/... - pass
  • golangci-lint run ./internal/format/npm/... - 0 issues
  • go vet -tags integration ./internal/format/npm/... - clean
  • Integration tests (//go:build integration) drive the handler against the S06 PgBlobStore over testcontainers; run in the merged-results pipeline.

Related to #122 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading