Initial Commit
This commit is contained in:
161
openspec/archive/2026-05-16-add-storage-http/design.md
Normal file
161
openspec/archive/2026-05-16-add-storage-http/design.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Design — @suaveplan/storage-http
|
||||
|
||||
Non-normative companion to `proposal.md` and `specs/storage-http/spec.md`. Architecture, algorithms, and trade-offs only — no testable requirements live here.
|
||||
|
||||
## 1. Mental model
|
||||
|
||||
`HttpStorage` is an `AbstractStorage` view over a URL prefix. The mapping is:
|
||||
|
||||
```text
|
||||
key "a/b/test.doc"
|
||||
baseUrl "https://cdn.example.com/start/here/"
|
||||
URL "https://cdn.example.com/start/here/a/b/test.doc"
|
||||
op →
|
||||
get GET <URL>
|
||||
exists HEAD <URL>
|
||||
getMetadata HEAD <URL> (or cached from previous GET)
|
||||
getPartial GET <URL>, Range: bytes=<o>-<o+l-1>
|
||||
set uploader.put(key, body, headers, signal) [if uploader wired]
|
||||
delete uploader.delete(key, signal) [if uploader supports it]
|
||||
list throw STORAGE_HTTP.READ_ONLY (always — see proposal §"Out of scope")
|
||||
```
|
||||
|
||||
Read-side is always available; write-side is gated by the presence of an `HttpUploader`. The split is captured in `capabilities`:
|
||||
|
||||
```ts
|
||||
{
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: false, // request body is committed in one call; not torn, but not the POSIX rename guarantee either
|
||||
contentAddressable: false,
|
||||
}
|
||||
```
|
||||
|
||||
## 2. File layout
|
||||
|
||||
```text
|
||||
src/
|
||||
index.ts # explicit named re-exports
|
||||
error-codes.ts # defineCodes("STORAGE_HTTP", [...]) + StorageHttpError
|
||||
types/
|
||||
http-storage.types.ts # HttpStorageOptions, HttpUploader, FetchLike
|
||||
http-storage.schemas.ts # HttpStorageOptionsSchema (Zod)
|
||||
fetch-adapter/
|
||||
fetch-adapter.ts # defaultFetch() wrapping globalThis.fetch
|
||||
fetch-adapter.md
|
||||
fetch-adapter.test.ts
|
||||
key-sanitizer/
|
||||
key-sanitizer.ts # sanitizeKey + per-segment encoder
|
||||
key-sanitizer.md
|
||||
key-sanitizer.test.ts
|
||||
range/
|
||||
range.ts # Range header builder + 206/200 disambiguation
|
||||
range.md
|
||||
range.test.ts
|
||||
etag-cache/
|
||||
etag-cache.ts # in-memory LRU; If-None-Match wiring
|
||||
etag-cache.md
|
||||
etag-cache.test.ts
|
||||
uploader/
|
||||
uploader.ts # HttpUploader contract, presignedPutUploader helper
|
||||
uploader.md
|
||||
uploader.test.ts
|
||||
retry-helper/
|
||||
retry-helper.ts # transient-status classifier + Retry-After honour
|
||||
retry-helper.md
|
||||
retry-helper.test.ts
|
||||
otel/
|
||||
otel.ts # meter/tracer wiring; recordOp(op, outcome, status_class, ms, bytes?)
|
||||
otel.md
|
||||
otel.test.ts
|
||||
http-storage/
|
||||
http-storage.ts # HttpStorage extends AbstractStorage
|
||||
http-storage.md
|
||||
http-storage.test.ts
|
||||
http-storage.spec-conformance.test.ts
|
||||
registry/
|
||||
registry.ts # declare module "@suaveplan/storage" + registerHttpProvider
|
||||
registry.md
|
||||
registry.test.ts
|
||||
```
|
||||
|
||||
Every implementation file ≤300 lines (soft 250); every `.test.ts` ≤500.
|
||||
|
||||
## 3. Algorithms / decisions
|
||||
|
||||
### 3a. Key → URL resolution
|
||||
|
||||
`new URL(encodedKey, baseUrl)` is the only safe primitive. Pitfalls:
|
||||
|
||||
- `baseUrl` MUST end with `/`; the Zod schema rejects non-`/` suffixes. Without the trailing slash, `new URL("a", "https://x/b")` resolves to `https://x/a` (the last path segment is treated as a file).
|
||||
- `encodedKey` is built by splitting the sanitised key on `/`, applying `keyEncoder` to each segment, then joining. This preserves the hierarchy (`a/b/test.doc` → `a/b/test.doc`) while encoding spaces, `?`, `#`, etc. (`hello world.txt` → `hello%20world.txt`).
|
||||
- Keys with a leading `/` are rejected by `sanitizeKey` because `new URL("/abs", "https://x/b/")` resolves to `https://x/abs` — losing the prefix.
|
||||
|
||||
### 3b. Status code → error code map
|
||||
|
||||
| HTTP | Error code | Retryable? |
|
||||
|---|---|---|
|
||||
| `200`, `206` | (success) | n/a |
|
||||
| `304` | (success — cached body returned) | n/a |
|
||||
| `401` | `UNAUTHORIZED` | no |
|
||||
| `403` | `FORBIDDEN` | no |
|
||||
| `404`, `410` | `NOT_FOUND` | no |
|
||||
| `408`, `425`, `429` | `UPSTREAM_ERROR` (then `NETWORK_ERROR` after retry exhaustion) | yes |
|
||||
| `5xx` | `UPSTREAM_ERROR` (then exhaustion → still `UPSTREAM_ERROR` with `retries_exhausted` attr) | yes |
|
||||
| `4xx` (other) | `UPSTREAM_ERROR` | no |
|
||||
| `fetch` reject (DNS, TLS, abort-without-signal) | `NETWORK_ERROR` | yes (network class) |
|
||||
| Pre-aborted signal | `ABORTED` (no request issued) | n/a |
|
||||
|
||||
`Retry-After` (seconds or HTTP-date) is honoured when present; otherwise the `@suaveplan/retry` exponential backoff is used. Jitter is delegated to `@suaveplan/retry`.
|
||||
|
||||
### 3c. Range reads
|
||||
|
||||
`getPartial(offset, length)` issues `Range: bytes=<offset>-<offset+length-1>`. Three response shapes are handled:
|
||||
|
||||
1. **`206 Partial Content`** — happy path. `Content-Range: bytes <o>-<o+l-1>/<total>` is parsed for size accounting; body is returned verbatim.
|
||||
2. **`200 OK`** — origin does not support Range. Full body is consumed and sliced; `storage_http.range_fallback_total` is incremented. Useful diagnostic for misconfigured origins.
|
||||
3. **`416 Range Not Satisfiable`** — caller asked beyond EOF. Mapped to `STORAGE_HTTP.NOT_FOUND` when the resource itself exists with shorter length (HEAD probe distinguishes "object missing" from "range past EOF" only on first encounter).
|
||||
|
||||
### 3d. ETag cache
|
||||
|
||||
Optional in-memory `Map`-backed LRU (default capacity: 256 entries). Keyed by sanitised key, valued by `{ etag, body, lastModified, expiresAt }`. On `get`:
|
||||
|
||||
1. If `cache.has(key)`, attach `If-None-Match: <etag>`.
|
||||
2. On `304`, return the cached body; refresh `lastModified` if present.
|
||||
3. On `200`, replace the cache entry with the new body (subject to LRU eviction).
|
||||
|
||||
The cache is **per-instance**, never shared across `HttpStorage` instances. Cross-process caching is composed by the caller (typically `storage-file` underneath).
|
||||
|
||||
### 3e. Write strategy
|
||||
|
||||
`HttpUploader` is the entire write surface. Its contract:
|
||||
|
||||
```ts
|
||||
interface HttpUploader {
|
||||
put(key: string, body: Uint8Array | ReadableStream<Uint8Array>, headers?: Record<string, string>, signal?: AbortSignal): Promise<void>;
|
||||
delete?(key: string, signal?: AbortSignal): Promise<void>; // optional
|
||||
}
|
||||
```
|
||||
|
||||
`HttpStorage.set` calls `uploader.put`; `delete` calls `uploader.delete` (or throws `READ_ONLY` if the uploader does not implement it). `createWriteStream` collects to memory and dispatches `uploader.put` on `close` — request-bodied origins do not support true chunked uploads without provider-specific multipart logic (deliberately out of scope).
|
||||
|
||||
The shipped `presignedPutUploader(presigner: (key) => Promise<{ url, headers? }>)` helper composes signing with `defaultFetch`. It is provider-agnostic — S3, R2, GCS, Azure Blob, or a custom endpoint all work by plugging in a presigner.
|
||||
|
||||
### 3f. Tier purity
|
||||
|
||||
Universal tier: `fetch`, `URL`, `AbortController`, `Uint8Array`, `ReadableStream` are all in the universal subset (Node 18+, modern browsers, Bun, Deno, Workers). No Node-only or DOM-only imports. The package is published with `"browser"` field omitted (universal) and no `engines` Node-version gate beyond the workspace baseline.
|
||||
|
||||
## 4. Trade-offs considered
|
||||
|
||||
- **Listing.** Rejected. WebDAV `PROPFIND` is the only HTTP-native directory protocol and is not what CDNs serve. Forcing a "best-effort" listing via index pages would lock callers into a specific origin's HTML layout. Shipping no `list` is honest.
|
||||
- **Built-in disk cache.** Rejected for the v0. Cache invalidation is the consumer's policy choice (TTL vs. ETag vs. revalidate-always). Composing `storage-http` behind a `storage-file` `NamespacedStorage` covers the disk-cache case without baking opinions into the adapter.
|
||||
- **Provider-specific helpers in this package.** Rejected. The moment we ship `s3PresignedPutUploader`, we drag `@aws-sdk/*` into the dep graph. The shipped `presignedPutUploader(presigner)` is the seam — provider packages live elsewhere.
|
||||
- **Atomic writes.** HTTP `PUT` is idempotent but not POSIX-rename-atomic. We declare `capabilities.atomicWrites: false` honestly. Callers that need atomicity (cache-coherent reads during writes) should layer their own staging logic.
|
||||
- **Conditional writes (`If-Match`).** Considered. Not v0 — would expand `HttpUploader` contract and the matching error code (`PRECONDITION_FAILED`). Easy follow-up if a consumer asks.
|
||||
|
||||
## 5. References
|
||||
|
||||
- `openspec/specs/storage/spec.md` — `AbstractStorage` contract this adapter conforms to.
|
||||
- `openspec/archive/2026-05-12-storage-file/` — closest precedent for adapter shape (capabilities, error registry, retry wiring).
|
||||
- `openspec/archive/2026-04-24-enterprise-storage-s3/` — network-backed adapter precedent (OTel attribute set, retry classification).
|
||||
85
openspec/archive/2026-05-16-add-storage-http/proposal.md
Normal file
85
openspec/archive/2026-05-16-add-storage-http/proposal.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Add @suaveplan/storage-http — URL/CDN-backed AbstractStorage adapter
|
||||
|
||||
**Package**: `@suaveplan/storage-http`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-http/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships filesystem (`storage-file`), S3 (`storage-s3`), browser (`storage-browser-{opfs,indexeddb,localstorage}`), and in-memory (`@suaveplan/storage`) adapters, but no adapter for the most common read-side surface on the public internet: **a base URL plus a key**. Consumers that need to read static assets served from a CDN (`https://cdn.example.com/start/here/` + `a/b/test.doc`) currently have to bypass the `AbstractStorage` contract and hand-roll `fetch` — losing OTel instrumentation, AbortSignal threading, structured error mapping, capability discovery, and the module-augmentation registry. The drive plugin (`drive-template` hydration), i18n HTTP backend, and any browser-tier consumer that pulls signed-URL assets all benefit from a real `AbstractStorage` over HTTPS.
|
||||
|
||||
The adapter is **read-first** with a pluggable write strategy. Plain CDNs (Cloudflare, Fastly, CloudFront read-paths, static origins) are read-only and the adapter SHALL declare that via `capabilities` and throw `STORAGE_HTTP.READ_ONLY` on writes. CDNs backed by an upload origin (S3 presigned PUT, R2 signed URLs, custom upload endpoints) plug in via an injectable `HttpUploader` interface; the adapter ships **no** baked-in uploader to avoid coupling to any specific provider.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `HttpStorage` class extending `AbstractStorage` — full key-value read surface over `fetch`, with optional write via injected `HttpUploader`.
|
||||
- ADDED: `createHttpStorage(options)` factory.
|
||||
- ADDED: `HttpStorageOptions` interface + `HttpStorageOptionsSchema` Zod schema. Required `baseUrl` (must end with `/`); optional `headers` (static or per-request callback), `fetch` (injectable `FetchLike` for testing), `timeout`, `retry` (delegates to `@suaveplan/retry`), `uploader` (`HttpUploader` for writes), `keyEncoder` (default: `encodeURIComponent` per path segment, preserving `/`), `etagCache` (in-memory weak cache for conditional requests).
|
||||
- ADDED: `FetchLike` structural interface — minimal `(input, init?) => Promise<Response>` shape; `defaultFetch()` adapter wrapping `globalThis.fetch` with `AbortSignal` plumbing.
|
||||
- ADDED: `HttpUploader` injectable interface — `{ put(key, body, headers?, signal?), delete?(key, signal?) }`. Adapter MAY ship a thin `presignedPutUploader(presigner)` helper but no provider-specific uploaders.
|
||||
- ADDED: `sanitizeKey(key)` strict key boundary — rejects empty, `..` segments, absolute paths (`/`-prefixed), null bytes, control chars (`0x00`–`0x1F`, `0x7F`), keys over 2048 chars (`KEY_TOO_LONG`); normalises Windows `\` to POSIX `/`; URL-encodes each path segment via the `keyEncoder` while preserving `/`.
|
||||
- ADDED: `STORAGE_HTTP_CODES` registry via `defineCodes("STORAGE_HTTP", [...])` — 10 codes: `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `NETWORK_ERROR`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `READ_ONLY`, `ABORTED`, `UPSTREAM_ERROR`.
|
||||
- ADDED: `StorageHttpError extends SuaveplanError<StorageHttpErrorCode>` with `key`, `httpStatus`, and `url` discriminants for diagnostics.
|
||||
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage"` adds `http: HttpStorage` to `StorageProviderRegistry` and `http: HttpStorageOptions` to `StorageProviderConfig`; `registerHttpProvider()` registers the constructor with the factory.
|
||||
- ADDED: Range-read support via HTTP `Range` header — `getPartial(offset, length)` issues `Range: bytes=<offset>-<offset+length-1>`; declared `capabilities.partialReads: true`. Servers that reply `200` instead of `206` are slow-pathed (full body sliced on the client) and counted under `storage_http.range_fallback_total`.
|
||||
- ADDED: `exists(key)` via `HEAD` request; `getMetadata(key)` reads `Content-Length`, `Content-Type`, `Last-Modified`, and `ETag` from a `HEAD` response (or piggy-backed on the last `GET` via `etagCache`).
|
||||
- ADDED: Conditional reads — when `etagCache` is enabled, `get` sends `If-None-Match` and treats `304 Not Modified` as a cache hit, returning the cached body without retransmission.
|
||||
- ADDED: Native Web Streams via `createReadStream(key, signal)` returning the `Response.body` directly when present (declared `capabilities.nativeStreams: true`). `createWriteStream` is unavailable on read-only configurations and throws `STORAGE_HTTP.READ_ONLY`; when an `HttpUploader` is injected, the write stream collects to memory and calls `uploader.put` on `close` (the underlying surface is request-bodied, not chunked).
|
||||
- ADDED: `@suaveplan/retry` integration on transient status codes (`408`, `425`, `429`, `5xx`) and network failures, with `Retry-After` honoured when present and exponential backoff otherwise. `4xx` other than the transient set SHALL NOT retry.
|
||||
- ADDED: OTel surface — `storage_http.op_duration_ms{op,outcome,status_class}` and `storage_http.io_bytes{op,direction}` histograms; `storage_http.{reads,writes,deletes,errors,range_fallback}_total{outcome}` counters; per-op span on `@suaveplan/storage-http` tracer with `http.url`, `http.method`, `http.status_code`, `http.response_content_length`, `storage.key` attributes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Provider-specific uploaders.** S3 presigned PUTs, R2 signed URLs, Azure Blob SAS, GCS signed URLs — each ships in its own package (e.g. `storage-http-uploader-s3`) or is composed by the consumer via the `HttpUploader` slot. Reason: keeps the core adapter zero-coupling and dependency-light.
|
||||
- **Listing (`list(prefix)`).** HTTP origins do not have a uniform directory protocol. `list` SHALL throw `STORAGE_HTTP.READ_ONLY` (capability `listing: false` declared on the adapter). Consumers that need listing should use `storage-s3` directly. Reason: any sensible implementation requires provider-specific knowledge (S3 `ListObjectsV2`, WebDAV `PROPFIND`, etc.); shipping an opinionated default would mislead callers.
|
||||
- **Persistent disk cache.** The optional `etagCache` is an in-memory LRU; cross-process caches belong to the consumer (a `NamespacedStorage` over `storage-file` is the recommended composition for that). Reason: cache invalidation policy is application-specific.
|
||||
- **Authentication state machine.** The `headers` callback runs on every request, so callers can plug in token rotation, but the adapter does not own the auth lifecycle. OAuth refresh, mTLS, AWS SigV4 — out of scope. Reason: orthogonal concern; standard composition pattern.
|
||||
- **`setPartial` random-access writes.** HTTP does not expose `pwrite` semantics uniformly (S3 `multipart-upload`, R2 `multipart`, GCS `resumable` are all incompatible). `setPartial` SHALL fall through to `AbstractStorage.defaultSetPartial` only when an `HttpUploader` is wired AND the storage is read-write; otherwise it throws `STORAGE_HTTP.READ_ONLY`. `capabilities.partialWrites: false`.
|
||||
- **WebDAV.** A genuine `PROPFIND`/`COPY`/`MOVE` adapter is a separate package.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This is a **genesis-original** package; there is no upstream in `../suaveplan/`. All requirements derive from the genesis `AbstractStorage` contract (`openspec/specs/storage/spec.md`) and the `storage-file`/`storage-s3` adapter precedents. The package contract follows the same shape as `storage-file` (atomic writes are inapplicable; the analogue is "request body is committed in one call").
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Extend `AbstractStorage` | Every storage adapter in genesis extends `AbstractStorage` for `StorageProviderRegistry` compatibility. |
|
||||
| `AbortSignal` on every public async method | Required by `AbstractStorage` base signatures; threaded straight into `fetch`'s `init.signal`. |
|
||||
| `baseUrl` must end with `/` | Avoids the classic `URL` resolution footgun where `new URL("a", "https://x.com/b")` yields `https://x.com/a` instead of `https://x.com/b/a`. Documented; validated by Zod schema. |
|
||||
| `keyEncoder` injectable | Some CDNs require unencoded `+` or specific casing for signed-URL signatures; per-segment encoding is the sane default but must be overridable. |
|
||||
| `HttpUploader` slot rather than baked-in writes | HTTP write APIs are not standardised; coupling the adapter to one provider would force every consumer to depend on that provider. The slot keeps the core read-only-by-default while permitting any write strategy. |
|
||||
| `READ_ONLY` capability + error code | Lets consumers test write capability without trying-and-catching, and surfaces the failure with a stable error code when they don't. |
|
||||
| Range fallback metric | Quietly accepting `200` for a `Range` request is a correctness hazard for large objects; emitting a counter lets operators alert on misconfigured origins. |
|
||||
| `etagCache` is in-memory only | Cross-process caching is the consumer's job (compose with `storage-file`). |
|
||||
| `STORAGE_HTTP.UPSTREAM_ERROR` distinct from `NETWORK_ERROR` | `5xx` is a real server response; `NETWORK_ERROR` is `fetch` reject (DNS, TLS, abort-without-signal). Distinguishing them helps retry classifiers and operators. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta carries a `**Verified by:**` line referencing concrete `it(...)` titles in these files:
|
||||
|
||||
- `src/http-storage/http-storage.test.ts` — CRUD via injected `FetchLike`, range reads, conditional reads with ETag cache, AbortSignal threading, retry on 429/503 + `Retry-After`, `READ_ONLY` enforcement when no uploader, write path with injected uploader, OTel happy paths.
|
||||
- `src/http-storage/http-storage.spec-conformance.test.ts` — type-level conformance against `AbstractStorage`, capabilities matrix, OTel instruments observed via real in-memory `MeterProvider`, idempotent registry registration, error-code registry exhaustion.
|
||||
- `src/key-sanitizer/key-sanitizer.test.ts` — empty, traversal, absolute, null bytes, control chars, overlong (>2048), separator normalisation, default per-segment encoding (preserves `/`, encodes `?`, `#`, space).
|
||||
- `src/fetch-adapter/fetch-adapter.test.ts` — `defaultFetch()` forwards to `globalThis.fetch`, propagates `AbortSignal`, maps network rejection to `STORAGE_HTTP.NETWORK_ERROR`.
|
||||
- `src/uploader/uploader.test.ts` — `presignedPutUploader` helper round-trips a presigned URL signer; injected uploader is invoked exactly once on `set`.
|
||||
- `src/range/range.test.ts` — `Range` header format, `206` happy path, `200` fallback path increments `range_fallback_total`, `416 Range Not Satisfiable` maps to `NOT_FOUND` when length unknown.
|
||||
- `src/etag-cache/etag-cache.test.ts` — LRU eviction, `304 Not Modified` returns cached body without retransmission, cache miss falls through.
|
||||
- `src/otel/otel.test.ts` — `recordOp` emits histogram + counter with `status_class` attribute against a real `InMemoryMetricExporter`.
|
||||
- `src/registry/registry.test.ts` — registers `http` provider, `createStorage({ type: "http", config })` returns `HttpStorage`, idempotent re-registration.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-http` to enumerate. The package is new — no dependents currently. No action needed.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-http` returning the package itself).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/storage` — `StorageProviderRegistry` module-augmented to add `http: HttpStorage` slot. Additive only; no breaking change. [x] no action needed (purely additive type slot).
|
||||
Binary file not shown.
77
openspec/archive/2026-05-16-add-storage-http/tasks.md
Normal file
77
openspec/archive/2026-05-16-add-storage-http/tasks.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Tasks — storage-http
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/specs/storage/spec.md` and confirm capability boundary
|
||||
- [ ] 1.2 Read `openspec/AGENTS.md` §0c, §13, §14
|
||||
- [ ] 1.3 Read `openspec/archive/2026-05-12-storage-file/` end-to-end as the closest precedent
|
||||
- [ ] 1.4 Read `openspec/archive/2026-04-24-enterprise-storage-s3/` for network-backed adapter precedent (OTel + retry classification)
|
||||
- [ ] 1.5 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.6 Draft `design.md` covering file layout, status→code map, range fallback, ETag cache, uploader contract, tier purity
|
||||
- [ ] 1.7 Draft `specs/storage-http/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.8 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete planned `it(...)` titles
|
||||
- [ ] 1.9 Build the numbered Requirement/Scenario implementation checklist (per §0c step 6)
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [ ] 2.1 Create `packages/storage/storage-http/` mirroring `packages/storage/storage-file/` conventions
|
||||
- [ ] 2.2 Write `package.json` v0.0.0, `"sideEffects": false`, subpath exports for every feature module
|
||||
- [ ] 2.3 Wire deps: `@suaveplan/error`, `@suaveplan/storage`, `@suaveplan/retry`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib` (all `workspace:^` / `catalog:`)
|
||||
- [ ] 2.4 Wire devDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [ ] 2.5 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (via `createTestConfig`)
|
||||
- [ ] 2.6 Write `tests/setup/polyfills.ts` and `README.md` skeleton
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
- [ ] 3.1 `src/error-codes.ts` — `defineCodes("STORAGE_HTTP", [...])` with 10 codes, `StorageHttpError` class, `key`/`httpStatus`/`url` discriminants
|
||||
- [ ] 3.2 `src/types/http-storage.types.ts` — `HttpStorageOptions`, `FetchLike`, `HttpUploader`, `EtagCacheOptions` interfaces
|
||||
- [ ] 3.3 `src/types/http-storage.schemas.ts` — `HttpStorageOptionsSchema` (Zod) with `baseUrl` http(s) + trailing-slash validation
|
||||
- [ ] 3.4 `src/fetch-adapter/fetch-adapter.ts` — `defaultFetch()` wrapping `globalThis.fetch`, `AbortSignal` propagation, network-reject → `NETWORK_ERROR`
|
||||
- [ ] 3.5 `src/key-sanitizer/key-sanitizer.ts` — `sanitizeKey` strict boundary + default per-segment `encodeURIComponent`
|
||||
- [ ] 3.6 `src/range/range.ts` — `Range` header builder + 206/200/416 disambiguation
|
||||
- [ ] 3.7 `src/etag-cache/etag-cache.ts` — in-memory LRU keyed by sanitised key
|
||||
- [ ] 3.8 `src/uploader/uploader.ts` — `HttpUploader` contract + `presignedPutUploader(presigner)` helper
|
||||
- [ ] 3.9 `src/retry-helper/retry-helper.ts` — transient status classifier + `Retry-After` honour via `@suaveplan/retry`
|
||||
- [ ] 3.10 `src/otel/otel.ts` — meter/tracer wiring, `recordOp(op, outcome, statusClass, ms, bytes?)`
|
||||
- [ ] 3.11 `src/http-storage/http-storage.ts` — `HttpStorage extends AbstractStorage` with all abstract methods, capabilities matrix, `READ_ONLY` enforcement, AbortSignal
|
||||
- [ ] 3.12 `src/registry/registry.ts` — `declare module "@suaveplan/storage"` + `registerHttpProvider()` (idempotent)
|
||||
- [ ] 3.13 `src/index.ts` — explicit named re-exports per §6a (no `export *` in any non-root barrel)
|
||||
|
||||
## 4. Tests
|
||||
|
||||
- [ ] 4.1 `src/key-sanitizer/key-sanitizer.test.ts` — valid, traversal, absolute, null bytes, control chars, overlong (>2048), separator normalisation, per-segment encoding
|
||||
- [ ] 4.2 `src/fetch-adapter/fetch-adapter.test.ts` — forwards to `globalThis.fetch`, `AbortSignal` propagation, network-reject → `NETWORK_ERROR`
|
||||
- [ ] 4.3 `src/range/range.test.ts` — 206 verbatim, 200 fallback + counter increment, 416 → `NOT_FOUND`
|
||||
- [ ] 4.4 `src/etag-cache/etag-cache.test.ts` — LRU eviction, 304 returns cached body, 200 replaces entry
|
||||
- [ ] 4.5 `src/uploader/uploader.test.ts` — `presignedPutUploader` round-trip; injected uploader invoked exactly once on `set`
|
||||
- [ ] 4.6 `src/retry-helper/retry-helper.test.ts` — transient classifier, `Retry-After` honour under `FakeClock`, non-retry on 401
|
||||
- [ ] 4.7 `src/otel/otel.test.ts` — metrics via real in-memory `MeterProvider`; error span via in-memory `SpanExporter`
|
||||
- [ ] 4.8 `src/http-storage/http-storage.test.ts` — GET round-trip, 404 → undefined, HEAD exists/metadata, AbortSignal pre/mid, READ_ONLY on `set`/`list`/`clear`, `set` with uploader invokes `put` once
|
||||
- [ ] 4.9 `src/http-storage/http-storage.spec-conformance.test.ts` — type conformance, capabilities matrix, every `STORAGE_HTTP` code reachable, tier-purity grep
|
||||
- [ ] 4.10 `src/registry/registry.test.ts` — `createStorage({ type: "http", … })` returns `HttpStorage`; idempotent re-registration
|
||||
- [ ] 4.11 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
- [ ] 4.12 No imports from `vitest` or `bun:test` in test files — all via `@suaveplan/testing/runner` (§7)
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [ ] 5.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 5.2 `README.md` links every module under `## Modules`
|
||||
- [ ] 5.3 README documents composition recipe: `storage-http` behind `storage-file` `NamespacedStorage` for cross-process disk cache
|
||||
|
||||
## 6. Gates
|
||||
|
||||
- [ ] 6.1 `bun run check`
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-http`
|
||||
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/storage-http` (100/100/100/100 Istanbul)
|
||||
- [ ] 6.4 `bun scripts/lint/check-test-runner-imports.ts` (rule 11)
|
||||
- [ ] 6.5 Tier-purity grep — `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-http/src/` returns empty
|
||||
- [ ] 6.6 `bun scripts/openspec-conformance.ts @suaveplan/storage-http`
|
||||
|
||||
## 7. Manifest & archival
|
||||
|
||||
- [ ] 7.1 Add package to `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 7.2 Update `STATUS.md` (genesis-original — add to "Completed" once shipped)
|
||||
- [ ] 7.3 Fold `specs/storage-http/spec.md` into `openspec/specs/storage-http/spec.md`
|
||||
- [ ] 7.4 Move `openspec/changes/add-storage-http/` → `openspec/archive/<yyyy-mm-dd>-add-storage-http/`
|
||||
Reference in New Issue
Block a user