Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,642 @@
<!-- markdownlint-disable MD024 -->
# 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 `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<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 `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<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 `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/<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"`