Files
suaveplan/openspec/changes/complete-storage-s3/specs/storage-s3/spec.md
2026-05-27 23:29:53 +01:00

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 AbstractStorage resolves to a non-inherited concrete implementation, and s3.capabilities matches 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 GetObjectCommand with Range: bytes=100-299 and 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 S3StorageError whose code is STORAGE_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 an S3 instance with default tuning
  • THEN the underlying client issues exactly one CreateMultipartUploadCommand, one or more UploadPartCommand, exactly one CompleteMultipartUploadCommand, and zero PutObjectCommand for 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 PutObjectCommand and zero CreateMultipartUploadCommand for 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 }) where stream yields exactly 25 MiB
  • THEN the upload completes with exactly 5 UploadPartCommand calls 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 setStream with 12 parts and concurrency 4, using an injected S3ClientFactory that records pending UploadPart promise count at each tick
  • THEN the recorded in-flight count never exceeds 4 and the final CompleteMultipartUploadCommand.MultipartUpload.Parts array is sorted by PartNumber

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 setStream on a 50 MiB stream then calls controller.abort() after the second part begins
  • THEN the call rejects, the underlying client receives at least one AbortMultipartUploadCommand for the key, and a subsequent ListMultipartUploadsCommand for 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 S3ClientFactory makes the third UploadPartCommand reject deterministically and the caller invokes setStream on a stream with at least three parts
  • THEN the call rejects with S3StorageError whose code is STORAGE_S3.MULTIPART_PART_FAILED and whose context.partNumber is 3

Scenario: cleanup failure does not mask original error

  • WHEN an UploadPart rejection triggers cleanup, and the injected AbortMultipartUploadCommand also rejects
  • THEN the call still rejects with the original STORAGE_S3.MULTIPART_PART_FAILED, and a logger record at warn level carries the STORAGE_S3.MULTIPART_ABORT_FAILED code

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 zero GetObjectCommand or PutObjectCommand for 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 GetObjectCommand followed by PutObjectCommand and zero CreateMultipartUploadCommand

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 with fetch()
  • 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 S3StorageError whose code is STORAGE_S3.PRESIGN_TTL_INVALID before 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 with sse: { kind: "sse-c", key, keyMd5 } and gets it back with the same sse block
  • THEN the round-tripped bytes equal the original

Scenario: SSE-C MD5 mismatch is rejected at boundary

  • WHEN a caller sets with sse: { kind: "sse-c", key, keyMd5 } where keyMd5 does not equal MD5(key)
  • THEN the call rejects with S3StorageError whose code is STORAGE_S3.SSE_KEY_MISMATCH before 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 with objectMetadata: { contentType: "image/png", cacheControl: "max-age=3600", userMetadata: { author: "x" }} and then calls head on the key
  • THEN the returned metadata exposes contentType === "image/png", cacheControl === "max-age=3600", and userMetadata.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 with acl: "public-read" and then calls getObjectAcl(key)
  • THEN the returned grants include the AllUsers group with READ permission

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 support sourceBucket (cross-bucket), metadataDirective: "COPY" | "REPLACE", taggingDirective: "COPY" | "REPLACE", conditional headers, and an SSE override. Copy failures SHALL surface STORAGE_S3.COPY_FAILED for the single-PUT path or STORAGE_S3.COPY_PART_FAILED for the multipart path with partNumber in context.

Scenario: cross-bucket copy

  • WHEN a caller invokes s3.copy("a", "b", { sourceBucket: "other" }) where other/a exists
  • THEN an object b exists in the configured bucket with byte-for-byte identical content to other/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 calls s3.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 invokes s3.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 calls getBucketLifecycle()
  • THEN the returned rules contain a single rule with id === "expire-tmp", prefix === "tmp/", and expirationDays === 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 calls getBucketCors()
  • 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 S3StorageError whose code is STORAGE_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 returned continuationToken into 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 S3StorageError whose code is STORAGE_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 with checksumAlgorithm: "SHA256" and a wrapping S3ClientFactory intercepts the GET response to corrupt one byte before delivery, and the caller gets with checksumMode: "ENABLED"
  • THEN the call rejects with S3StorageError whose code is STORAGE_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 invokes s3.head("k")
  • THEN customFetch is 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 S3ClientFactory makes the first two GetObjectCommand calls fail with a synthetic 503 and the third succeed, with retry: { 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-s3 runs
  • THEN it reports zero unused-error-code findings for STORAGE_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-backed S3Client from @suaveplan/testing-integration-garage-s3 rather 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-s3 is 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"