# 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=-` 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` 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 `set`s an object with `sse: { kind: "sse-c", key, keyMd5 }` and `get`s 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 `set`s 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` (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 `set`s 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 `set`s 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` 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 `set`s "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 `set`s with `checksumAlgorithm: "SHA256"` and a wrapping `S3ClientFactory` intercepts the GET response to corrupt one byte before delivery, and the caller `get`s 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//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"`