14 KiB
OpenSpec Change: complete-storage-s3
- Change ID: complete-storage-s3
- Package:
@suaveplan/storage-s3(modify — complete to full S3 parity) - Status: PROPOSED
- Date: 2026-05-17
- Design: ./design.md
- Spec delta: ./specs/storage-s3/spec.md
Why
@suaveplan/storage-s3 ships a working but thin AbstractStorage
implementation: it does get / set / delete / list / head, a serial
multipart setMultipart, presigned URLs limited to GET / PUT, and one copy
helper. Almost every other surface S3 exposes — and that consumers actually
need to ship production object-store workloads — is absent:
- No auto-promote of
setto multipart for large payloads; the existingsetMultipartis serial and not wired into theAbstractStoragesurface. - No streaming
setfromReadableStream<Uint8Array>without buffering. - No
getSignedDeleteUrl; presigning is fixed toGET/PUTwith no TTL semantics, no response/request header pinning, no content-type or content-disposition control. - No server-side encryption (SSE-S3, SSE-KMS, SSE-C) on
setorget. - No object metadata controls — content-type, content-encoding,
content-disposition, cache-control, user-defined
x-amz-meta-*. - No ACLs (canned or grant policies), no object tagging, no versioning
surface, no bucket lifecycle, no bucket CORS, no bucket-level ops (create
/ delete / exists / region), no conditional ops (
If-Match,If-None-Match,If-Modified-Since,If-Unmodified-Since). - No range reads via
get;getPartialround-trips the whole object via the default fallback. - No checksum integrity (SHA-256 / CRC32C) per AWS-recommended guard.
- Pluggable HTTP transport (
FetchLike), pluggable SigV4 signer, and retry composition via@suaveplan/retryare not yet integrated; retries are hand-rolled inwithRetry. - Error coverage is 11 codes for a surface that needs ~50.
- No real-service test harness wiring against the existing
@suaveplan/testing-integration-garage-s3fixture for the multi-feature paths above.
The supersession marker
openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-storage-s3-multipart/
confirms that the prior narrow deferral (multipart only) was rolled back in
favour of a single change that lands the full S3 surface at parity. This
change is that rollup. No cherry-picking, no Phase 2 split.
What Changes
This change brings @suaveplan/storage-s3 to full S3 feature parity in a
single shipped change. Every capability below is in scope; none are deferred.
AbstractStorage surface (production-grade)
get,set,setPartial,has/exists,delete,list,head/stat— each becomes production-grade with full options, conditional headers, SSE, and checksum integrity passthrough.getPartialuses native S3Range:reads — no more fetch-the-whole-object fallback.setPartialforoffset === 0ANDbytes.byteLength > thresholdroutes through the native multipart engine; non-zero offsets retaindefaultSetPartialr-m-w because S3 has no byte-range PUT.capabilities.partialWritesstaysfalseto honestly advertise that.
Multipart (auto-promote, bounded concurrency, abort+cleanup)
S3.setauto-promotes to multipart whenvalue.byteLengthexceeds a configurable threshold (default 16 MiB).- Internal
multipart-engineownsCreateMultipartUpload→ bounded-parallelUploadPart(default concurrency 4) →CompleteMultipartUpload, with full abort propagation to in-flight workers and guaranteedAbortMultipartUploadcleanup. - Per-stage error codes:
MULTIPART_INIT_FAILED,MULTIPART_PART_FAILED,MULTIPART_COMPLETE_FAILED,MULTIPART_ABORT_FAILED. LegacyMULTIPART_FAILEDretained as alias.
Streaming put
S3.setStream(key, stream, opts?)consumesReadableStream<Uint8Array>without buffering the whole stream — chunked into parts on the fly with a ceiling of 2 part-sized buffers in memory.
Presigned URLs
getSignedGetUrl,getSignedPutUrl,getSignedDeleteUrl— each with TTL, custom request headers, response-content-type and response-content-disposition overrides, content-type pinning, and SSE parameter forwarding.
Server-side encryption
- SSE-S3 (
AES256), SSE-KMS (aws:kmswith optionalSSEKMSKeyId+BucketKeyEnabled), SSE-C (caller-supplied key material) on everyset,setStream,setMultipart, presigned PUT, andcopy. getaccepts the matching SSE-C key for decryption.
Object metadata
setacceptscontentType,contentEncoding,contentDisposition,contentLanguage,cacheControl,expires, and a free-formuserMetadata: Record<string, string>mapped tox-amz-meta-*.head/getMetadatareturns the full metadata bag includingeTag,versionId,lastModified,storageClass, and the caller-set headers above.
ACLs
- Canned ACLs (
private,public-read,public-read-write,aws-exec-read,authenticated-read,bucket-owner-read,bucket-owner-full-control,log-delivery-write) per put. - Explicit grant policies (
grantRead,grantReadACP,grantWriteACP,grantFullControl) per put. getObjectAcl/putObjectAclfor post-write changes.
Copy
copyObject(source, destination, opts?)— server-side copy within bucket AND across buckets via{ sourceBucket }. Supports metadata directive (COPY/REPLACE), tagging directive, conditional headers, SSE parameter overrides.UploadPartCopyengine for objects > 5 GiB (multipart server-side copy).
Tagging
getObjectTags,putObjectTags,deleteObjectTags.setaccepts inlinetags: Record<string, string>(URL-encoded into thex-amz-taggingheader — no extra round trip).
Versioning
listObjectVersionspaginator.get(key, { versionId }),head(key, { versionId }),delete(key, { versionId }).
Bucket-level operations
createBucket,deleteBucket,bucketExists,getBucketRegion,listObjectsV2(paginator withprefix,delimiter,maxKeys,continuationToken,startAfter).- Lifecycle config:
getBucketLifecycle,putBucketLifecycle,deleteBucketLifecycle. - CORS config:
getBucketCors,putBucketCors,deleteBucketCors.
Conditional ops
IfMatch,IfNoneMatch,IfModifiedSince,IfUnmodifiedSinceonget/head/delete/copy— translated to the precondition error codePRECONDITION_FAILED(412) andNOT_MODIFIED(304).
Range reads
get(key, { range: { offset, length } })issues a nativeRange:GET rather than a full-object fetch + slice.
Checksums
- Per-request
SHA256(default) andCRC32Cintegrity guards per the AWS-recommended additional checksum modes. ReturnedChecksumSHA256/ChecksumCRC32Care surfaced on the metadata bag and verified onget-with-checksum.
Pluggable HTTP transport, signer, retry
S3Options.transport?: FetchLiketo swap the underlying HTTP transport (browserfetch, Nodeundici, custom edge fetch). Default uses the AWS SDK's built-in.S3Options.signer?: SigV4Signerto swap the signer (default uses AWS SDK SigV4 implementation).- Retry is composed through
@suaveplan/retry(createRetryStrategy) — the hand-rolledwithRetryis replaced. Throttling / 5xx classification preserved.
Error codes
S3_CODES expands from 11 to ~50 codes covering every failure path:
SSE (SSE_KEY_MISSING, SSE_KEY_MISMATCH, SSE_KMS_DENIED),
ACLs (ACL_INVALID, ACL_PUT_FAILED),
tagging (TAG_PUT_FAILED, TAG_DELETE_FAILED, TAG_LIMIT_EXCEEDED),
versioning (VERSION_NOT_FOUND, VERSIONING_DISABLED),
lifecycle (LIFECYCLE_INVALID, LIFECYCLE_PUT_FAILED),
CORS (CORS_INVALID, CORS_PUT_FAILED),
bucket ops (BUCKET_ALREADY_EXISTS, BUCKET_NOT_EMPTY, BUCKET_DELETE_FAILED,
BUCKET_CREATE_FAILED, BUCKET_REGION_MISMATCH),
conditional (PRECONDITION_FAILED, NOT_MODIFIED),
copy (COPY_FAILED, COPY_PART_FAILED),
multipart (4 new codes above; MULTIPART_FAILED retained),
presign (PRESIGN_TTL_INVALID, PRESIGN_HEADER_INVALID),
checksums (CHECKSUM_MISMATCH, CHECKSUM_ALGORITHM_UNSUPPORTED),
range (RANGE_INVALID),
auth (SIGNATURE_INVALID, TOKEN_EXPIRED),
transport (TRANSPORT_FAILED).
Tests via real Garage container
@suaveplan/testing-integration-garage-s3(already shipped) hosts every integration test. No mocks. The fixture is extended in this change if any capability surface (e.g.PutBucketLifecycleCommand) needs additional helper exposure.
100% coverage gate
- Per project rule:
100/100/100/100istanbul, gated bybunx turbo test:coverage --filter=@suaveplan/storage-s3.
Parity Checklist
This change has no upstream ../suaveplan/packages/storage-s3/ to migrate
from (the upstream package was never authored there — confirmed by ls
returning No such file or directory). The parity target is the full
public S3 API surface as documented by AWS, not an upstream library.
Therefore the table below is structured by S3 capability rather than upstream
file:
| Capability | S3 native | Migrated | Notes |
|---|---|---|---|
get / set / delete / list / head |
✅ | ✅ | Production-grade with full options. |
setPartial (whole-object via multipart) |
✅ | ✅ | Non-zero offset retains r-m-w; partialWrites: false. |
getPartial via native Range: |
✅ | ✅ | Replaces fetch-whole-then-slice. |
| Multipart auto-promote + parallel + abort | ✅ | ✅ | Default threshold 16 MiB, concurrency 4. |
Streaming setStream |
✅ | ✅ | ReadableStream<Uint8Array> without buffering. |
| Presigned GET / PUT / DELETE | ✅ | ✅ | TTL + headers + content-type pin. |
| SSE-S3 / SSE-KMS / SSE-C | ✅ | ✅ | Per put / get / copy. |
| Object metadata (content-* + user-metadata) | ✅ | ✅ | Full bag. |
| Canned ACLs + grant policies + getObjectAcl/putObjectAcl | ✅ | ✅ | |
copyObject (single-PUT + UploadPartCopy) |
✅ | ✅ | Cross-bucket supported. |
| Object tagging (get/put/delete + inline at set) | ✅ | ✅ | |
| Versioning (list versions, versionId on get/head/delete) | ✅ | ✅ | |
| Bucket lifecycle (get/put/delete) | ✅ | ✅ | |
| Bucket CORS (get/put/delete) | ✅ | ✅ | |
| Bucket ops (create / delete / exists / region) | ✅ | ✅ | |
listObjectsV2 paginator |
✅ | ✅ | Prefix/delimiter/max/continuation/startAfter. |
| Conditional ops (IfMatch / IfNoneMatch / If-*Modified-Since) | ✅ | ✅ | |
| Checksums (SHA256, CRC32C) | ✅ | ✅ | |
Pluggable transport + signer + @suaveplan/retry |
n/a | ✅ | Genesis-only requirement. |
Comprehensive STORAGE_S3.* codes |
n/a | ✅ | ~50 codes. |
| Real Garage integration tests | n/a | ✅ | Via testing-integration-garage-s3. |
No row is 🚫. Every capability lands in this change.
Out of scope
Strictly things outside the S3 protocol identity:
- CloudFront integration (signed-cookie / signed-URL generation against
a CloudFront key-pair, OAI / OAC origin-access-control). Belongs in a
separate
@suaveplan/storage-cdn-cloudfrontpackage. - AWS-account-level operations — IAM policy authoring, STS role
assumption beyond the standard credential provider chain, KMS key
creation / rotation. Out of
storage-s3's remit. - Resumable uploads with cross-process persistence of
UploadId+ completed-part list. Multipart resume mid-process is supported via the engine; cross-restart resume requires a separate coordinator. - S3 Object Lambda / Object Lock retention modes — distinct surface, not part of the core object-store contract.
- Bucket policies / public-access blocks beyond the ACL surface above.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-s3 to enumerate. Expected
dependents (verified at implementation time):
@suaveplan/storage— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason:storage-s3consumesAbstractStorage; no surface-of-storagechange.)@suaveplan/drive-plugin-chunks— [x] tests run / [x] docs reviewed / [ ] no action needed (reason: declared consumer ofsetPartialand range reads viaDriveStorage; benefits directly from the native range GET and large-object write path.)@suaveplan/drive-plugin-cache— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: cache layer round-trips byte ranges; new range path is observable but contract unchanged.)@suaveplan/storage-cdn/storage-cdn-*— [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: CDN adapters do not consumestorage-s3directly.)
storage-s3 is not a foundation package per §14a, but the dependent sweep
(§13 step 10, bunx turbo test --filter=...@suaveplan/storage-s3) MUST run
before archive because the multipart / range / SSE wiring is observable to
chunked-drive consumers.
Migration
The change is mostly additive on the public surface; the few behaviour changes that consumers can observe are listed below with grep commands:
setfor large payloads now issues multipart — observable viaS3ServiceExceptionshape; no behaviour change for size ≤ 16 MiB.getPartialnow uses native Range — observable via wire traffic; contract identical (same bytes returned).- Retry composition now flows through
@suaveplan/retry— observable only if callers introspect retry timing. Error codes preserved. S3_CODESgains ~40 new entries — consumers asserting on the legacy 11 codes are unaffected; new failure paths surface the new codes.
Search command for consumers depending on the legacy error-code surface:
grep -rln "S3_CODES\." packages/ | grep -v "/dist/\|/coverage/\|/storage-s3/"