Initial Commit
This commit is contained in:
642
openspec/changes/complete-storage-s3/specs/storage-s3/spec.md
Normal file
642
openspec/changes/complete-storage-s3/specs/storage-s3/spec.md
Normal 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"`
|
||||
Reference in New Issue
Block a user