27 KiB
Capability Spec Delta: storage-s3 (complete-storage-s3)
Package: @suaveplan/storage-s3
Status: PROPOSED
Every requirement below carries a
**Verified by:**line authored at draft time. Paths name files this change will create; they do not exist yet.
ADDED Requirements
Requirement: AbstractStorage compliance with honest capability flags
The S3 class SHALL extend AbstractStorage and implement every abstract
method (get, set, setPartial, delete, exists, list,
getMetadata, getWithMetadata, getSize, getPartial, getItemSize,
createReadStream, createWriteStream, clear). It SHALL expose
capabilities = { partialReads: true, partialWrites: false, nativeStreams: true, atomicWrites: true, contentAddressable: false }.
partialWrites SHALL remain false because S3 has no byte-range PUT.
Scenario: every abstract method is implemented
- WHEN a consumer instantiates
new S3(opts)and inspects the prototype chain - THEN every abstract method declared on
AbstractStorageresolves to a non-inherited concrete implementation, ands3.capabilitiesmatches the documented flag set verbatim
Verified by: src/s3/s3.test.ts::"implements every AbstractStorage method"
AND src/s3/s3.test.ts::"reports the documented capability flags"
Requirement: Native range reads via getPartial
S3.getPartial(key, offset, length, signal?) SHALL issue a
GetObjectCommand with Range: bytes=<offset>-<offset+length-1> rather
than fetching the full object. Out-of-range requests SHALL raise
S3StorageError with code STORAGE_S3.RANGE_NOT_SATISFIABLE. Zero-length
requests SHALL return an empty Uint8Array without issuing a network
call.
Scenario: partial GET issues a single ranged request
- WHEN a caller invokes
s3.getPartial("k", 100, 200)on an existing 10 KiB object - THEN the underlying client issues exactly one
GetObjectCommandwithRange: bytes=100-299and returns exactly 200 bytes
Scenario: out-of-range raises typed error
- WHEN a caller invokes
s3.getPartial("k", 1024, 1024)on a 512-byte object - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.RANGE_NOT_SATISFIABLE
Verified by: tests/integration/range.test.ts::"partial GET issues a single ranged request"
AND tests/integration/range.test.ts::"out-of-range raises RANGE_NOT_SATISFIABLE"
Requirement: Auto-promote set to multipart above threshold
S3.set(key, value, options?, signal?) SHALL internally upload value via
S3 multipart (CreateMultipartUpload → bounded-parallel UploadPart →
CompleteMultipartUpload) when value.byteLength exceeds
S3Options.multipart.threshold (default 16 MiB). At or below the threshold
the call SHALL issue a single PutObjectCommand. The public signature and
observable success semantics SHALL NOT change.
Scenario: large byte array triggers multipart
- WHEN a caller invokes
s3.set("k", new Uint8Array(20 * 1024 * 1024))against anS3instance with default tuning - THEN the underlying client issues exactly one
CreateMultipartUploadCommand, one or moreUploadPartCommand, exactly oneCompleteMultipartUploadCommand, and zeroPutObjectCommandfor that key
Scenario: small byte array uses single PUT
- WHEN a caller invokes
s3.set("k", new Uint8Array(1024)) - THEN the underlying client issues exactly one
PutObjectCommandand zeroCreateMultipartUploadCommandfor that key
Verified by: tests/integration/multipart.test.ts::"large byte array triggers multipart"
AND tests/integration/multipart.test.ts::"small byte array uses single PUT"
Requirement: Streaming put without whole-stream buffering
S3.setStream(key, stream, opts?) SHALL consume a
ReadableStream<Uint8Array> and upload it via the multipart engine. At no
point SHALL the implementation buffer more than two parts' worth of bytes
in memory (in-flight read + accumulating chunker buffer). The final part
MAY be shorter than partSize.
Scenario: 25 MiB stream produces five 5 MiB parts
- WHEN a caller invokes
s3.setStream("k", stream, { partSize: 5 * 1024 * 1024 })wherestreamyields exactly 25 MiB - THEN the upload completes with exactly 5
UploadPartCommandcalls and the round-tripped GET equals the input byte-for-byte
Verified by: src/multipart/part-chunker.test.ts::"emits exact part-size chunks then a ragged final part"
AND tests/integration/stream.test.ts::"25 MiB stream produces five 5 MiB parts"
Requirement: Bounded multipart concurrency and ordered parts
The multipart engine SHALL upload parts with at most
S3Options.multipart.concurrency (default 4) UploadPartCommand calls in
flight simultaneously. Concurrency SHALL be validated at construction
time to lie in the inclusive range [1, 32]; out-of-range values SHALL
throw S3StorageError(STORAGE_S3.MULTIPART_INIT_FAILED, ...) at
construction. The final MultipartUpload.Parts list SHALL be sorted
ascending by PartNumber regardless of completion order.
Scenario: concurrency cap is honoured
- WHEN a caller invokes
setStreamwith 12 parts and concurrency 4, using an injectedS3ClientFactorythat records pendingUploadPartpromise count at each tick - THEN the recorded in-flight count never exceeds 4 and the final
CompleteMultipartUploadCommand.MultipartUpload.Partsarray is sorted byPartNumber
Verified by: src/multipart/worker-pool.test.ts::"never exceeds the configured concurrency"
AND tests/integration/multipart.test.ts::"concurrency cap is honoured under injected part latency"
Requirement: Abort propagation and guaranteed multipart cleanup
The multipart engine SHALL react to a caller-supplied AbortSignal firing
mid-upload by cancelling every in-flight UploadPartCommand via a linked
AbortController signal passed to S3Client.send, SHALL stop drawing
further parts from the input iterator, and SHALL issue exactly one
AbortMultipartUploadCommand for the open UploadId before re-throwing.
The cleanup AbortMultipartUploadCommand SHALL use a fresh signal so it
runs to completion even after the caller aborts.
Scenario: caller abort cancels parts and cleans up
- WHEN a caller starts
setStreamon a 50 MiB stream then callscontroller.abort()after the second part begins - THEN the call rejects, the underlying client receives at least one
AbortMultipartUploadCommandfor the key, and a subsequentListMultipartUploadsCommandfor the bucket returns no open uploads for that key
Verified by: tests/integration/multipart.test.ts::"caller abort cancels parts and cleans up"
Requirement: Per-stage multipart error codes
Multipart failures SHALL raise S3StorageError with stage-specific codes:
STORAGE_S3.MULTIPART_INIT_FAILED when CreateMultipartUpload fails or
returns no UploadId; STORAGE_S3.MULTIPART_PART_FAILED when an
UploadPart call fails after retries, with partNumber populated in
context; STORAGE_S3.MULTIPART_COMPLETE_FAILED when
CompleteMultipartUpload fails; STORAGE_S3.MULTIPART_ABORT_FAILED when
the cleanup AbortMultipartUpload itself fails (logged at warn level;
MUST NOT replace the original thrown error). STORAGE_S3.MULTIPART_FAILED
SHALL remain registered as a back-compat alias and SHALL NOT be raised by
any post-change call site.
Scenario: part failure surfaces stage-specific code
- WHEN a wrapping
S3ClientFactorymakes the thirdUploadPartCommandreject deterministically and the caller invokessetStreamon a stream with at least three parts - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.MULTIPART_PART_FAILEDand whosecontext.partNumberis 3
Scenario: cleanup failure does not mask original error
- WHEN an
UploadPartrejection triggers cleanup, and the injectedAbortMultipartUploadCommandalso rejects - THEN the call still rejects with the original
STORAGE_S3.MULTIPART_PART_FAILED, and a logger record at warn level carries theSTORAGE_S3.MULTIPART_ABORT_FAILEDcode
Verified by: src/multipart/multipart-engine.test.ts::"maps stage failures to dedicated codes"
AND tests/integration/multipart.test.ts::"cleanup failure is logged and does not mask original error"
Requirement: setPartial whole-object writes use multipart
S3.setPartial(key, offset, bytes, signal?) SHALL delegate to the
multipart engine when offset === 0 and bytes.byteLength exceeds the
multipart threshold. For all other cases it SHALL continue to use
AbstractStorage#defaultSetPartial's read-modify-write fallback.
S3.capabilities.partialWrites SHALL remain false.
Scenario: zero-offset large write takes multipart path
- WHEN a caller invokes
s3.setPartial("k", 0, new Uint8Array(20 * 1024 * 1024)) - THEN the underlying client issues
CreateMultipartUploadCommand+UploadPartCommand(s) +CompleteMultipartUploadCommand, and zeroGetObjectCommandorPutObjectCommandfor the key
Scenario: non-zero offset uses read-modify-write
- WHEN a caller invokes
s3.setPartial("k", 1024, new Uint8Array(512))on an existing 4 KiB object - THEN the underlying client issues
GetObjectCommandfollowed byPutObjectCommandand zeroCreateMultipartUploadCommand
Verified by: tests/integration/multipart.test.ts::"setPartial at offset zero uses multipart"
AND tests/integration/multipart.test.ts::"setPartial at non-zero offset uses read-modify-write"
Requirement: Presigned GET / PUT / DELETE URLs
S3 SHALL expose getSignedGetUrl(key, opts?), getSignedPutUrl(key, opts?), and getSignedDeleteUrl(key, opts?). Each SHALL accept
expiresIn (1 ≤ TTL ≤ 7 days), requestHeaders,
responseContentType, responseContentDisposition, contentType (PUT
only), sse, checksumAlgorithm, and versionId (GET / DELETE only) as
applicable. Invalid TTL SHALL raise
STORAGE_S3.PRESIGN_TTL_INVALID; invalid header SHALL raise
STORAGE_S3.PRESIGN_HEADER_INVALID.
Scenario: presigned GET round-trips an object
- WHEN a caller invokes
getSignedGetUrl("k", { expiresIn: 60 })and immediately fetches the returned URL withfetch() - THEN the response status is 200 and the body equals the stored bytes
Scenario: invalid TTL is rejected at boundary
- WHEN a caller invokes
getSignedGetUrl("k", { expiresIn: 0 }) - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.PRESIGN_TTL_INVALIDbefore any network call is issued
Verified by: tests/integration/presign.test.ts::"presigned GET round-trips an object"
AND src/presign/get-signed-get-url.test.ts::"rejects out-of-range expiresIn at boundary"
Requirement: Server-side encryption SHALL support SSE-S3, SSE-KMS, SSE-C
The adapter SHALL accept an sse?: SseConfig discriminated union on
S3.set, S3.setStream, S3.setMultipart, S3.copy, and the presigned
PUT builder. The union members are { kind: "sse-s3" }, { kind: "sse-kms"; keyId?: string; bucketKeyEnabled?: boolean }, or { kind: "sse-c"; key: Uint8Array; keyMd5: Uint8Array }. For SSE-C, keyMd5 SHALL be validated against
MD5(key) at the boundary; a mismatch SHALL raise
STORAGE_S3.SSE_KEY_MISMATCH before the request leaves the process.
S3.get / S3.head SHALL accept the matching SSE-C key for decryption;
omitting it on an SSE-C-encrypted object SHALL surface
STORAGE_S3.SSE_KEY_MISSING.
Scenario: SSE-C round-trip with correct key
- WHEN a caller
sets an object withsse: { kind: "sse-c", key, keyMd5 }andgets it back with the samesseblock - THEN the round-tripped bytes equal the original
Scenario: SSE-C MD5 mismatch is rejected at boundary
- WHEN a caller
sets withsse: { kind: "sse-c", key, keyMd5 }wherekeyMd5does not equalMD5(key) - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.SSE_KEY_MISMATCHbefore any network call is issued
Verified by: tests/integration/sse.test.ts::"SSE-C round-trip with correct key"
AND src/sse/sse.test.ts::"rejects mismatched keyMd5 at the boundary"
Requirement: Object metadata (content-*, cache-control, user-metadata)
S3.set and S3.setStream SHALL accept an objectMetadata?: ObjectMetadata bag containing contentType, contentEncoding,
contentDisposition, contentLanguage, cacheControl, expires, and
userMetadata: Record<string, string> (mapped to x-amz-meta-*).
S3.head and S3.getMetadata SHALL return the full metadata bag
including eTag, versionId, lastModified, storageClass, every
content-* header, every x-amz-meta-* entry, and SSE-algorithm
indicator.
Scenario: metadata round-trip
- WHEN a caller
sets an object withobjectMetadata: { contentType: "image/png", cacheControl: "max-age=3600", userMetadata: { author: "x" }}and then callsheadon the key - THEN the returned metadata exposes
contentType === "image/png",cacheControl === "max-age=3600", anduserMetadata.author === "x"
Verified by: tests/integration/core.test.ts::"object metadata round-trips through head"
Requirement: ACLs (canned + grant policies + get/put)
S3.set SHALL accept acl?: CannedAcl | GrantPolicy. Canned values are
private, public-read, public-read-write, aws-exec-read,
authenticated-read, bucket-owner-read, bucket-owner-full-control,
log-delivery-write. Grant policies accept grantRead, grantReadACP,
grantWriteACP, grantFullControl. S3 SHALL expose getObjectAcl(key, opts?) and putObjectAcl(key, acl, opts?). Invalid ACL identifiers SHALL
raise STORAGE_S3.ACL_INVALID at the boundary.
Scenario: canned ACL is applied at put
- WHEN a caller
sets an object withacl: "public-read"and then callsgetObjectAcl(key) - THEN the returned grants include the
AllUsersgroup withREADpermission
Verified by: tests/integration/acl.test.ts::"canned ACL is applied at put"
AND tests/integration/acl.test.ts::"putObjectAcl replaces grants on existing object"
Requirement: Server-side copy (within-bucket + cross-bucket)
S3.copy(source, destination, opts?) SHALL issue a single
CopyObjectCommand when the source object is ≤ 5 GiB; for sources
5 GiB it SHALL route to the multipart server-side copy engine (
UploadPartCopy). The options bag SHALL supportsourceBucket(cross-bucket),metadataDirective: "COPY" | "REPLACE",taggingDirective: "COPY" | "REPLACE", conditional headers, and an SSE override. Copy failures SHALL surfaceSTORAGE_S3.COPY_FAILEDfor the single-PUT path orSTORAGE_S3.COPY_PART_FAILEDfor the multipart path withpartNumberincontext.
Scenario: cross-bucket copy
- WHEN a caller invokes
s3.copy("a", "b", { sourceBucket: "other" })whereother/aexists - THEN an object
bexists in the configured bucket with byte-for-byte identical content toother/a
Verified by: tests/integration/copy.test.ts::"cross-bucket copy"
AND tests/integration/copy.test.ts::"UploadPartCopy is used for sources above five GiB"
Requirement: Object tagging (get/put/delete + inline at set)
S3.set SHALL accept tags?: Record<string, string> URL-encoded into the
x-amz-tagging header at PUT time (single round trip). S3 SHALL expose
getObjectTags(key, opts?), putObjectTags(key, tags, opts?), and
deleteObjectTags(key, opts?). More than MAX_TAGS (10) tags on any
operation SHALL raise STORAGE_S3.TAG_LIMIT_EXCEEDED at the boundary.
Scenario: inline tags at set are queryable
- WHEN a caller invokes
s3.set("k", bytes, { tags: { env: "prod" } })and then callss3.getObjectTags("k") - THEN the returned map equals
{ env: "prod" }
Verified by: tests/integration/tagging.test.ts::"inline tags at set are queryable"
AND tests/integration/tagging.test.ts::"more than ten tags raises TAG_LIMIT_EXCEEDED"
Requirement: Versioning support (list, get/head/delete by versionId)
S3 SHALL expose listObjectVersions(opts?) as a paginator yielding
ObjectVersion records. S3.get, S3.head, and S3.delete SHALL
accept an opts.versionId and forward it to the underlying SDK
commands. STORAGE_S3.VERSION_NOT_FOUND SHALL surface when the requested
version does not exist; STORAGE_S3.VERSIONING_DISABLED SHALL surface
when a versionId is supplied against a bucket without versioning enabled.
Scenario: get-by-versionId returns the historical bytes
- WHEN a caller
sets "k" twice (v1, v2) on a versioned bucket and then invokess3.get("k", { versionId: v1 }) - THEN the returned bytes equal the v1 payload
Verified by: tests/integration/versioning.test.ts::"get by versionId returns historical bytes"
Requirement: Bucket lifecycle configuration
S3 SHALL expose getBucketLifecycle(opts?), putBucketLifecycle(rules, opts?), and deleteBucketLifecycle(opts?). Rule shape SHALL be validated
by a Zod schema at the boundary; invalid rules SHALL raise
STORAGE_S3.LIFECYCLE_INVALID before the request leaves the process.
Scenario: lifecycle rules round-trip
- WHEN a caller invokes
putBucketLifecycle([{ id: "expire-tmp", prefix: "tmp/", expirationDays: 7 }])and then callsgetBucketLifecycle() - THEN the returned rules contain a single rule with
id === "expire-tmp",prefix === "tmp/", andexpirationDays === 7
Verified by: tests/integration/lifecycle.test.ts::"lifecycle rules round-trip"
Requirement: Bucket CORS configuration
S3 SHALL expose getBucketCors(opts?), putBucketCors(rules, opts?),
and deleteBucketCors(opts?). Rule shape SHALL be validated by a Zod
schema at the boundary; invalid rules SHALL raise
STORAGE_S3.CORS_INVALID before the request leaves the process.
Scenario: CORS rules round-trip
- WHEN a caller invokes
putBucketCors([{ allowedOrigins: ["*"], allowedMethods: ["GET"], maxAgeSeconds: 60 }])and then callsgetBucketCors() - THEN the returned rules contain a single rule equal to the input
Verified by: tests/integration/cors.test.ts::"CORS rules round-trip"
Requirement: Bucket-level operations
S3 SHALL expose createBucket(opts?), deleteBucket(opts?),
bucketExists(opts?), and getBucketRegion(opts?). createBucket on an
existing bucket SHALL surface STORAGE_S3.BUCKET_ALREADY_EXISTS.
deleteBucket on a non-empty bucket SHALL surface
STORAGE_S3.BUCKET_NOT_EMPTY. bucketExists SHALL return false on 404
and true on 200. getBucketRegion SHALL return the bucket's home
region as a string.
Scenario: deleteBucket on non-empty bucket raises typed error
- WHEN a caller invokes
deleteBucket()on a bucket with at least one object - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.BUCKET_NOT_EMPTY
Verified by: tests/integration/bucket.test.ts::"deleteBucket on non-empty bucket raises BUCKET_NOT_EMPTY"
AND tests/integration/bucket.test.ts::"createBucket on existing raises BUCKET_ALREADY_EXISTS"
Requirement: listObjectsV2 paginator with full options
S3.list(opts?) SHALL accept prefix, delimiter, maxKeys (1..1000),
continuationToken, and startAfter, and SHALL return a
StorageListResult including a continuationToken for further pages
when the underlying ListObjectsV2 response is truncated.
Scenario: pagination via continuationToken
- WHEN a bucket holds 2500 objects and a caller pages through with
maxKeys: 1000, threading the returnedcontinuationTokeninto the next call - THEN three calls return 1000 + 1000 + 500 keys with no duplicates and no omissions
Verified by: tests/integration/bucket.test.ts::"listObjectsV2 paginates via continuationToken"
Requirement: Conditional operations
S3.get, S3.head, S3.delete, and S3.copy SHALL accept
ConditionalOptions = { ifMatch?, ifNoneMatch?, ifModifiedSince?, ifUnmodifiedSince? }. The adapter SHALL translate HTTP 412 responses to
STORAGE_S3.PRECONDITION_FAILED and HTTP 304 responses (where applicable
to get / head) to STORAGE_S3.NOT_MODIFIED.
Scenario: IfMatch with stale eTag raises PRECONDITION_FAILED
- WHEN a caller invokes
s3.get("k", { ifMatch: "stale-etag" })on an existing object whose current eTag is different - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.PRECONDITION_FAILED
Verified by: tests/integration/conditional.test.ts::"IfMatch with stale eTag raises PRECONDITION_FAILED"
AND tests/integration/conditional.test.ts::"IfNoneMatch with current eTag raises NOT_MODIFIED on get"
Requirement: Checksums (SHA-256 + CRC32C)
S3.set and S3.setStream SHALL accept checksumAlgorithm?: "SHA256" | "CRC32C". When supplied, the adapter SHALL compute and send
the checksum header (x-amz-checksum-sha256 / x-amz-checksum-crc32c)
with the request. S3.get SHALL accept checksumMode: "ENABLED" and
SHALL verify the returned checksum against the body; a mismatch SHALL
raise STORAGE_S3.CHECKSUM_MISMATCH. Unsupported algorithm strings
SHALL raise STORAGE_S3.CHECKSUM_ALGORITHM_UNSUPPORTED at the boundary.
Scenario: checksum mismatch raises typed error
- WHEN a caller
sets withchecksumAlgorithm: "SHA256"and a wrappingS3ClientFactoryintercepts the GET response to corrupt one byte before delivery, and the callergets withchecksumMode: "ENABLED" - THEN the call rejects with
S3StorageErrorwhosecodeisSTORAGE_S3.CHECKSUM_MISMATCH
Verified by: tests/integration/checksum.test.ts::"checksum mismatch raises typed error"
AND tests/integration/checksum.test.ts::"SHA256 and CRC32C round-trip cleanly"
Requirement: Pluggable HTTP transport and SigV4 signer
S3Options SHALL accept transport?: FetchLike and signer?: SigV4Signer. When transport is provided it SHALL be wired into the
underlying S3Client via the SDK's requestHandler / Fetch plug
point. When signer is provided it SHALL replace the SDK's default
SignatureV4 provider. Defaults preserve the AWS SDK's built-in
behaviour and SHALL be observable as the no-op identity when no plug is
provided.
Scenario: custom transport is invoked for every request
- WHEN a caller constructs
new S3({ ..., transport: customFetch })and invokess3.head("k") - THEN
customFetchis called at least once and the underlying AWS SDK's default fetch is not called
Verified by: src/transport/transport.test.ts::"custom transport is invoked for every request"
AND src/signer/signer.test.ts::"custom signer replaces the default SigV4 provider"
Requirement: Retry composition via @suaveplan/retry
The adapter SHALL compose its retry behaviour via createRetryStrategy
from @suaveplan/retry rather than a hand-rolled loop. Classification
SHALL preserve the existing semantics (5xx, throttling, SlowDown,
RequestTimeout are retryable; all other failures are fatal).
S3Options.retry SHALL accept maxAttempts, baseDelayMs, and
maxDelayMs; defaults are 3, 100, 30000.
Scenario: retryable failure is retried up to maxAttempts
- WHEN a wrapping
S3ClientFactorymakes the first twoGetObjectCommandcalls fail with a synthetic 503 and the third succeed, withretry: { maxAttempts: 3 } - THEN the call resolves with the expected bytes and the total number of underlying calls is exactly 3
Verified by: src/retry/retry.test.ts::"retryable failure is retried up to maxAttempts"
AND src/retry/retry.test.ts::"fatal failure is not retried"
Requirement: Comprehensive STORAGE_S3.* error code registry
S3_CODES SHALL be registered via defineCodes("STORAGE_S3", [...] as const) and SHALL include every code listed in
design.md#error-code-map. Every registered code SHALL be raised by at
least one call site under src/; the post-archive openspec conformance
check SHALL pass. Legacy codes (the original 11) SHALL remain at their
existing identifiers; MULTIPART_FAILED SHALL remain registered as a
back-compat alias with no new call site.
Scenario: every registered code is reachable
- WHEN the openspec conformance script
bun scripts/openspec-conformance.ts @suaveplan/storage-s3runs - THEN it reports zero
unused-error-codefindings forSTORAGE_S3.*
Verified by: src/error-codes.test.ts::"registry exposes every documented code"
AND src/error-codes.test.ts::"MULTIPART_FAILED is registered as a back-compat alias"
Requirement: Subpath exports for every feature module
package.json SHALL expose a subpath export entry for every feature
module documented in design.md#file-layout. The root barrel
(src/index.ts) SHALL re-export each feature module under a namespace
(export * as multipart from "./multipart/index.js", etc.) per project
rule §6a. Feature barrels (src/<feature>/index.ts) SHALL use explicit
named re-exports — no export *.
Scenario: subpath import resolves and tree-shakes
- WHEN a consumer writes
import { runMultipart } from "@suaveplan/storage-s3/multipart/multipart-engine" - THEN the import resolves to the engine implementation and the
consumer's bundle does not include
S3, presign, ACL, lifecycle, CORS, bucket, or tagging modules unless separately imported
Verified by: src/multipart/multipart-engine.test.ts::"is reachable via the dedicated subpath export"
AND src/s3/s3.test.ts::"feature barrels use explicit named re-exports"
Requirement: End-to-end verification against a real S3-compatible service
The package SHALL exercise every requirement above end-to-end against a
real S3-compatible service via testcontainers per project rule §2 (no
mocks). The genesis monorepo's
@suaveplan/testing-integration-garage-s3 fixture SHALL be the host. No
unit test MAY substitute a mocked AWS SDK for these behaviours; failure
injection MAY use a wrapping S3ClientFactory that intercepts specific
commands, but the underlying transport MUST remain real. Where Garage's
behaviour diverges from AWS (e.g. SSE-KMS key-policy enforcement), the
test SHALL assert the documented Garage behaviour and the fixture
surface SHALL be extended in this same change.
Scenario: integration suite runs against the Garage container
- WHEN the integration test config is executed via
bunx turbo test:integration --filter=@suaveplan/storage-s3 - THEN every test in
tests/integration/uses the Garage-backedS3Clientfrom@suaveplan/testing-integration-garage-s3rather than a mocked client, and the suite exits zero
Verified by: tests/integration/core.test.ts::"integration suite is wired to the Garage testcontainer fixture"
Requirement: 100% coverage gate
The package SHALL meet the project-wide 100/100/100/100 istanbul
coverage gate (statements / branches / functions / lines). The
pre-publish gate bunx turbo test:coverage --filter=@suaveplan/storage-s3 SHALL hard-fail below 100% on any of the
four metrics. Coverage SHALL include unit tests under src/**/*.test.ts
AND integration tests under tests/integration/**/*.test.ts; integration
tests SHALL be counted via the merged istanbul report.
Scenario: coverage gate is enforced
- WHEN the publish gate
bunx turbo test:coverage --filter=@suaveplan/storage-s3is run - THEN the gate exits zero only when statements / branches / functions / lines are all at 100%
Verified by: vitest.config.ts thresholds block AND
vitest.integration.config.ts thresholds block AND
tests/integration/core.test.ts::"merged coverage report is generated"