Files
2026-05-27 23:29:53 +01:00

302 lines
14 KiB
Markdown

# 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 `set` to multipart for large payloads; the existing
`setMultipart` is serial and not wired into the `AbstractStorage` surface.
- No streaming `set` from `ReadableStream<Uint8Array>` without buffering.
- No `getSignedDeleteUrl`; presigning is fixed to `GET` / `PUT` with 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 `set` or `get`.
- 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`; `getPartial` round-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/retry` are not yet integrated; retries are
hand-rolled in `withRetry`.
- Error coverage is 11 codes for a surface that needs ~50.
- No real-service test harness wiring against the existing
`@suaveplan/testing-integration-garage-s3` fixture 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.
- `getPartial` uses native S3 `Range:` reads — no more fetch-the-whole-object
fallback.
- `setPartial` for `offset === 0` AND `bytes.byteLength > threshold` routes
through the native multipart engine; non-zero offsets retain
`defaultSetPartial` r-m-w because S3 has no byte-range PUT.
`capabilities.partialWrites` stays `false` to honestly advertise that.
### Multipart (auto-promote, bounded concurrency, abort+cleanup)
- `S3.set` auto-promotes to multipart when `value.byteLength` exceeds a
configurable threshold (default 16 MiB).
- Internal `multipart-engine` owns `CreateMultipartUpload` → bounded-parallel
`UploadPart` (default concurrency 4) → `CompleteMultipartUpload`, with
full abort propagation to in-flight workers and guaranteed
`AbortMultipartUpload` cleanup.
- Per-stage error codes: `MULTIPART_INIT_FAILED`, `MULTIPART_PART_FAILED`,
`MULTIPART_COMPLETE_FAILED`, `MULTIPART_ABORT_FAILED`. Legacy
`MULTIPART_FAILED` retained as alias.
### Streaming put
- `S3.setStream(key, stream, opts?)` consumes `ReadableStream<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:kms` with optional `SSEKMSKeyId` +
`BucketKeyEnabled`), SSE-C (caller-supplied key material) on every
`set`, `setStream`, `setMultipart`, presigned PUT, and `copy`.
- `get` accepts the matching SSE-C key for decryption.
### Object metadata
- `set` accepts `contentType`, `contentEncoding`, `contentDisposition`,
`contentLanguage`, `cacheControl`, `expires`, and a free-form
`userMetadata: Record<string, string>` mapped to `x-amz-meta-*`.
- `head` / `getMetadata` returns the full metadata bag including
`eTag`, `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` / `putObjectAcl` for 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.
- `UploadPartCopy` engine for objects > 5 GiB (multipart server-side copy).
### Tagging
- `getObjectTags`, `putObjectTags`, `deleteObjectTags`.
- `set` accepts inline `tags: Record<string, string>` (URL-encoded into the
`x-amz-tagging` header — no extra round trip).
### Versioning
- `listObjectVersions` paginator.
- `get(key, { versionId })`, `head(key, { versionId })`,
`delete(key, { versionId })`.
### Bucket-level operations
- `createBucket`, `deleteBucket`, `bucketExists`, `getBucketRegion`,
`listObjectsV2` (paginator with `prefix`, `delimiter`, `maxKeys`,
`continuationToken`, `startAfter`).
- Lifecycle config: `getBucketLifecycle`, `putBucketLifecycle`,
`deleteBucketLifecycle`.
- CORS config: `getBucketCors`, `putBucketCors`, `deleteBucketCors`.
### Conditional ops
- `IfMatch`, `IfNoneMatch`, `IfModifiedSince`, `IfUnmodifiedSince` on
`get` / `head` / `delete` / `copy` — translated to the precondition error
code `PRECONDITION_FAILED` (412) and `NOT_MODIFIED` (304).
### Range reads
- `get(key, { range: { offset, length } })` issues a native `Range:` GET
rather than a full-object fetch + slice.
### Checksums
- Per-request `SHA256` (default) and `CRC32C` integrity guards per the
AWS-recommended additional checksum modes. Returned `ChecksumSHA256` /
`ChecksumCRC32C` are surfaced on the metadata bag and verified on
`get`-with-checksum.
### Pluggable HTTP transport, signer, retry
- `S3Options.transport?: FetchLike` to swap the underlying HTTP transport
(browser `fetch`, Node `undici`, custom edge fetch). Default uses the
AWS SDK's built-in.
- `S3Options.signer?: SigV4Signer` to swap the signer (default uses AWS SDK
SigV4 implementation).
- Retry is composed through `@suaveplan/retry` (`createRetryStrategy`) —
the hand-rolled `withRetry` is 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/100` istanbul, gated by
`bunx 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-cloudfront` package.
- **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-s3` consumes `AbstractStorage`; no
surface-of-`storage` change.)
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed /
[ ] no action needed (reason: declared consumer of `setPartial` and
range reads via `DriveStorage`; 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 consume
`storage-s3` directly.)
`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:
1. **`set` for large payloads now issues multipart** — observable via
`S3ServiceException` shape; no behaviour change for size ≤ 16 MiB.
2. **`getPartial` now uses native Range** — observable via wire traffic;
contract identical (same bytes returned).
3. **Retry composition now flows through `@suaveplan/retry`** — observable
only if callers introspect retry timing. Error codes preserved.
4. **`S3_CODES` gains ~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:
```bash
grep -rln "S3_CODES\." packages/ | grep -v "/dist/\|/coverage/\|/storage-s3/"
```