Initial Commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# Design — StructuredCloneCodec for @suaveplan/cache-storage
|
||||
|
||||
> Non-normative. Normative requirements live in `./specs/cache-storage/spec.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a third `Codec<T>` implementation to `@suaveplan/cache-storage` that round-trips the structured-clone-ish subset of JS values — `Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`, and arbitrarily nested combinations — over `@suaveplan/cbor`. The default `JsonCodec` remains untouched; callers opt in.
|
||||
|
||||
## Codec interface (unchanged)
|
||||
|
||||
```ts
|
||||
// existing — packages/flow/cache-storage/src/codec/codec.types.ts
|
||||
export interface Codec<T> {
|
||||
encode(value: T): Uint8Array;
|
||||
decode(bytes: Uint8Array): T;
|
||||
}
|
||||
```
|
||||
|
||||
The new codec implements this interface verbatim. No constructor surface beyond the bare class is required for the default case; a future `{ tags?: ReadonlyMap<number, TagHandler> }` option is sketched below but is out of scope for this change.
|
||||
|
||||
## Tag scheme
|
||||
|
||||
CBOR tags from the IANA "Concise Binary Object Representation (CBOR) Tags" registry. Choosing standard tags keeps the on-wire format interop-friendly (a non-JS reader can decode the blob with any RFC 8949 implementation plus the standard tag set).
|
||||
|
||||
| JS type | CBOR tag | Payload shape | IANA reference |
|
||||
|---------------|----------|----------------------------------------------------------------|---------------------------------|
|
||||
| `Date` | `0` | RFC 3339 string (`date.toISOString()`) | RFC 8949 §3.4.1 |
|
||||
| `bigint > 0` | `2` | byte string, big-endian magnitude | RFC 8949 §3.4.3 (positive bignum)|
|
||||
| `bigint < 0` | `3` | byte string, big-endian magnitude of `-1 - n` | RFC 8949 §3.4.3 (negative bignum)|
|
||||
| `URL` | `32` | string (the `URL.href`) | RFC 8949 §3.4.5.3 (URI) |
|
||||
| `RegExp` | `35` | string (`regex.source`); flags lost — see "Trade-offs" below | RFC 8949 §3.4.5.4 (PCRE regex) |
|
||||
| `Uint8Array` | `64` | byte string | RFC 8746 §3.3 |
|
||||
| `Set` | `258` | array of items (recursively encoded) | https://github.com/input-output-hk/cbor-sets-spec |
|
||||
| `Map` | `259` | array of `[key, value]` pairs (recursively encoded) | https://github.com/shanewholloway/js-cbor-codec/blob/master/docs/CBOR-259-spec--explicit-maps.md |
|
||||
|
||||
Plain objects, arrays, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR — no transformation, no tag.
|
||||
|
||||
> Note on the Map choice: RFC 8949 §3.1 major type 5 already carries maps natively, but CBOR maps key on the encoded *bytes* of the key, which means a JS `Map` with object keys would either lose distinct identity (every `{}` encodes to the same bytes) or throw. Tag `259` was introduced by the JS ecosystem specifically to disambiguate "this came from a JS Map and the keys are values, not strings" — picking it preserves round-trip without breaking the major-type-5 invariants for plain-object encoding.
|
||||
|
||||
## Encode pipeline
|
||||
|
||||
```text
|
||||
input value
|
||||
│
|
||||
▼
|
||||
walk(value) ── recursive lift
|
||||
│ plain JS value ─► returned as-is
|
||||
│ carrier type ─► returned as new Tagged(tag, walk(payload))
|
||||
│ unsupported type ─► throw CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE
|
||||
│
|
||||
▼
|
||||
encode(walked, { deterministic: false }) ── @suaveplan/cbor
|
||||
│
|
||||
▼
|
||||
Uint8Array
|
||||
```
|
||||
|
||||
Detection order is intentional — most specific first — so that subclasses do not slip through. `Array.isArray` is checked before "plain object"; `instanceof Date`, `instanceof Map`, etc. come before the duck-typed fallthrough.
|
||||
|
||||
## Decode pipeline
|
||||
|
||||
```text
|
||||
input bytes
|
||||
│
|
||||
▼
|
||||
decode(bytes, { tagHandling: "registry", tags: TAG_REGISTRY }) ── @suaveplan/cbor
|
||||
│
|
||||
▼ each Tagged hit
|
||||
TagHandler[tag](payload) ── reverse-lift
|
||||
│ known tag ─► returns the reconstructed JS carrier
|
||||
│ unknown tag ─► throw CACHE_STORAGE.CODEC_UNKNOWN_TAG
|
||||
│
|
||||
▼
|
||||
value
|
||||
```
|
||||
|
||||
Unknown tags fail loud rather than passing through as `Tagged` objects. Silent pass-through would change the runtime shape the caller observes versus what they wrote, breaking the `decode(encode(x)) deepEquals x` contract every other codec honours.
|
||||
|
||||
### TagHandler shape (from `@suaveplan/cbor`)
|
||||
|
||||
```ts
|
||||
type TagHandler = (payload: DecodedValue) => unknown;
|
||||
```
|
||||
|
||||
The codec builds a frozen `Map<number, TagHandler>` once at module load. The registry is `internal` for this change; a public `register(tag, handler)` surface is sketched in the "Future work" section below.
|
||||
|
||||
## Error handling
|
||||
|
||||
Two new entries extend `CACHE_STORAGE_CODES`:
|
||||
|
||||
| Code | Raised by | Caller-actionable response |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
|
||||
| `CODEC_UNSUPPORTED_TYPE` | `StructuredCloneCodec.encode` when the walk hits `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance without a registered tag | Switch to `JsonCodec` (for primitives only) or pre-serialise the value yourself |
|
||||
| `CODEC_UNKNOWN_TAG` | `StructuredCloneCodec.decode` when CBOR yields a `Tagged(n, …)` for `n` not in the registry | Upgrade the codec version, or pass a custom tag map (future-work surface) |
|
||||
|
||||
Existing `CODEC_ERROR` is reused for malformed CBOR bytes (catch-all for `CborError` thrown by `@suaveplan/cbor/decoder`) so the boundary remains a single "decode failed" code for back-compat — the new `CODEC_UNKNOWN_TAG` covers only the well-formed-but-unrecognised case.
|
||||
|
||||
## Fallback strategy when CBOR decoder hits an unknown tag
|
||||
|
||||
The codec configures `@suaveplan/cbor`'s decoder with a `TagRegistry` containing handlers for the eight tags above. For any other tag number, the registry returns a `Tagged` placeholder; the codec then inspects the decoded tree post-hoc — any leftover `Tagged` instance triggers `CACHE_STORAGE.CODEC_UNKNOWN_TAG` carrying the offending tag number in the error context. Walking the tree once for the post-check is O(n) over the decoded value count and runs at most once per `decode` call.
|
||||
|
||||
Why not configure the cbor decoder to throw immediately on unknown tags? Because `@suaveplan/cbor`'s tag-handling mode is a single decoder-wide flag; flipping it to "throw" would also kill any caller who later wants to mix this codec with a custom fallback handler. Inspecting post-hoc keeps the policy local to this codec.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/flow/cache-storage/src/codec/
|
||||
codec.types.ts # unchanged
|
||||
identity.ts # unchanged
|
||||
identity.test.ts # extended with conformance harness call
|
||||
identity.md # unchanged
|
||||
json.ts # unchanged
|
||||
json.test.ts # extended with conformance harness call
|
||||
json.md # unchanged
|
||||
structured-clone.ts # NEW — codec implementation (~180 LoC)
|
||||
structured-clone.test.ts # NEW — conformance + negative cases
|
||||
structured-clone.md # NEW — sibling docs
|
||||
conformance.ts # NEW — runCodecConformance harness
|
||||
conformance.test.ts # NEW — harness self-test
|
||||
conformance.md # NEW — sibling docs
|
||||
index.ts # extended — explicit named re-export added
|
||||
```
|
||||
|
||||
The 300-line soft cap is comfortably met. If the lift table grows (custom-tag registration), `structured-clone.tags.ts` extracts cleanly.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **`RegExp.flags` is dropped on round-trip.** RFC 8949's PCRE-regex tag carries only the pattern string; flags are not part of the registry payload spec. Documented in the sibling `.md`. Callers needing flags can wrap the regex in `{ source, flags }` and cache that.
|
||||
- **`number === NaN` / `±Infinity` survive CBOR but not JSON.** With `StructuredCloneCodec` these round-trip cleanly (CBOR has float specials); `JsonCodec` already turns them into `null`. Worth noting in docs but not a defect of this codec.
|
||||
- **No transferable objects.** `ArrayBuffer.transfer`, `MessagePort`, `ImageBitmap` are out of scope — they belong to browser-only structured clone, not a universal-tier cache codec.
|
||||
- **CBOR map key constraint.** A JS `Map` with object keys round-trips because we use tag `259` (array of `[k, v]` pairs), where each `k` is recursively encoded as a full CBOR item. The major-type-5 "duplicate keys collide" hazard does not apply.
|
||||
|
||||
## Future work (explicitly deferred)
|
||||
|
||||
- Public `StructuredCloneCodec` constructor option `{ tags?: ReadonlyMap<number, { encode?, decode? }> }` so callers can register their own carriers (e.g. `Decimal`, `Big`, domain value objects).
|
||||
- Promotion of `runCodecConformance` to `@suaveplan/testing` if a second consumer adopts the `Codec<T>` interface.
|
||||
- Streaming variant `StructuredCloneStreamCodec` for >10 MiB blobs once a real consumer asks. Current synchronous design is the right default.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Add StructuredCloneCodec (CBOR-backed) to @suaveplan/cache-storage
|
||||
|
||||
**Package**: `@suaveplan/cache-storage` (universal)
|
||||
**Status**: PROPOSED
|
||||
**Type**: additive — new optional codec, JSON default unchanged
|
||||
**Picks up deferred work from**: `openspec/archive/2026-05-16-add-cache-storage-redis-backends/`
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/cache-storage/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The archived change `add-cache-storage-redis-backends` shipped `StorageCache<T>` with two codecs (`JsonCodec`, `IdentityCodec`) and explicitly deferred the third under its `## Out of scope` heading:
|
||||
|
||||
> **`StructuredCloneCodec` / CBOR codec.** Deferred. JSON + Identity cover the common case; CBOR-backed structured-clone (`Date`/`Map`/`Set` faithful round-trip) ships in a follow-up alongside the cbor catalog entry. No placeholder code lands in this change.
|
||||
|
||||
That follow-up is now unblocked: `@suaveplan/cbor` ships on genesis with a complete RFC 8949 encoder/decoder, `Tagged` value, and registry-extensible tag handlers. Callers that cache rich JS values (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, nested combinations) currently either lose information through `JsonCodec` (`Date` → string; `Map`/`Set` → `{}` or `[]`; `Uint8Array` → object-with-numeric-keys; `bigint` → `JSON.stringify` throw) or are forced to author their own pre/post serializers. Neither is acceptable for an enterprise cache layer that already exposes `Codec<T>` as the documented swap-point.
|
||||
|
||||
This change adds an opt-in `StructuredCloneCodec` that round-trips the structured-clone-ish subset over `@suaveplan/cbor`, leaving `JsonCodec` as the back-compat default.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/cache-storage` (additive only)
|
||||
|
||||
- ADDED: `StructuredCloneCodec<T> implements Codec<T>` — new `src/codec/structured-clone.ts`. `encode` walks the input, lifts non-JSON-native carriers (`Date`, `Map`, `Set`, `Uint8Array`, `bigint`, `RegExp`, `URL`) into `Tagged(tagNumber, payload)` per the table in `design.md`, then delegates to `encode` from `@suaveplan/cbor`. `decode` calls `decode` from `@suaveplan/cbor` with a `TagHandler` map that reverses the lift. Plain objects, arrays, primitives, `null`, `boolean`, finite `number`, and `string` round-trip natively through CBOR with zero transformation.
|
||||
- ADDED: Tag numbers chosen from IANA-registered CBOR tags so the wire format is interop-friendly: `0` (RFC 3339 date string — used for `Date`), `258` (Set), `259` (Map with explicit key/value pairs), `64`–`72` reserved typed-array tags (`Uint8Array` uses tag `64`), `2`/`3` (positive/negative bignum for `bigint`), `35` (PCRE regex string for `RegExp`), `32` (URI for `URL`). All references are in `design.md`.
|
||||
- ADDED: Subpath export `./codec/structured-clone` in `package.json` matching the existing `./codec/json` and `./codec/identity` shape, plus re-export from `./src/codec/index.ts`.
|
||||
- ADDED: Two new entries in `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])`: `CODEC_UNSUPPORTED_TYPE` (raised on encode of a value the codec cannot lift — `Function`, `Symbol`, `WeakMap`, `WeakSet`, DOM nodes, `Promise`, class instance without a registered tag handler) and `CODEC_UNKNOWN_TAG` (raised on decode of a CBOR tag the codec does not recognise AND for which no caller-supplied fallback handler exists).
|
||||
- ADDED: Conformance harness `src/codec/conformance.ts` exporting `runCodecConformance(codec, { name, supportsStructuredClone })` — round-trip cases covering: primitives (null, boolean, number, string), nested plain objects, arrays, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive + negative), `RegExp`, `URL`, deeply nested combinations, empty collections, and (when `supportsStructuredClone: false`) the `unsupported-type-on-JSON` negative case. The existing `JsonCodec` and `IdentityCodec` tests gain a thin harness-invocation wrapper so all three codecs share one contract; the new codec's test file invokes it with `supportsStructuredClone: true`.
|
||||
|
||||
### `@suaveplan/cbor` (consumed; no changes)
|
||||
|
||||
- The `StructuredCloneCodec` imports `encode`, `decode`, `Tagged`, and `type TagHandler` directly from `@suaveplan/cbor` per CLAUDE.md rule 4 (no re-exports).
|
||||
|
||||
### Default codec unchanged
|
||||
|
||||
- `StorageCache`'s constructor still defaults `codec` to `new JsonCodec<T>()`. Existing callers see no behaviour change.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-realm `structuredClone()` parity.** The HTML structured-clone algorithm covers `ArrayBuffer` transfer, `ImageBitmap`, `Blob`, `File`, etc. This codec covers only the JS-language subset relevant to caching. Browser-only types stay out of a universal-tier package.
|
||||
- **Custom user tag registration.** The codec exposes the handler map internally; a public `registerTag` surface is a follow-up if real callers ask for it. Documented as such — not silently missing.
|
||||
- **Wiring the new codec into `@suaveplan/cache-redis`.** Out of scope here; `cache-redis` already consumes the `Codec<T>` interface from `@suaveplan/cache-storage/codec`, so callers can already `new StructuredCloneCodec()` and pass it. No code change is needed in `cache-redis` for that to work.
|
||||
- **Promoting the conformance harness to `@suaveplan/testing`.** The harness lives inside `cache-storage` for now; if other packages adopt the codec interface it gets lifted.
|
||||
|
||||
## Source mapping
|
||||
|
||||
- `openspec/archive/2026-05-16-add-cache-storage-redis-backends/proposal.md` — the deferral text quoted above.
|
||||
- `packages/flow/cache-storage/src/codec/` — existing `Codec<T>` seam (`codec.types.ts`, `json.ts`, `identity.ts`, `index.ts`). New file `structured-clone.ts` plugs into the same barrel and exports.
|
||||
- `packages/flow/cache-storage/src/storage-cache/storage-cache.ts:13-14,54,93,168` — codec seam (constructor parameter, encode/decode call sites). No changes required: the new codec is interface-compatible.
|
||||
- `packages/flow/cache-storage/src/errors/errors.ts` — `CACHE_STORAGE_CODES` registry to extend with two new codes via the same `defineCodes` call.
|
||||
- `packages/flow/cbor/src/index.ts` — public root barrel; `encode`, `decode`, `Tagged`, `type TagHandler` are the exports consumed.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Tag numbers chosen from the IANA CBOR tag registry (0, 2, 3, 32, 35, 64, 258, 259) | Standardised wire format is the only sane choice for a cache that may be read by a non-JS consumer (Redis blob, file dump). Inventing private tag numbers would lock the format to this codec forever. |
|
||||
| Conformance harness lives in `cache-storage`, not `@suaveplan/testing` | `@suaveplan/testing` carries zero `@suaveplan/*` runtime deps (rule 8). The harness must import the `Codec<T>` interface to type its parameter; lifting it would invert the dep graph. Promotion is a separate change if a second consumer appears. |
|
||||
| Unknown-tag default: throw `CODEC_UNKNOWN_TAG` (not silent pass-through) | A cache that silently turns an unknown tag into a `Tagged` wrapper changes the runtime type the caller observes versus what they cached. Failing loudly preserves the codec's round-trip contract and matches the `JsonCodec` "decode failure = throw" precedent. |
|
||||
| `RegExp` and `URL` lifted (not just structured-clone-spec types) | These are the two most common JS-host carriers that round-trip cleanly through one IANA tag each and that callers reliably cache. Adding them costs ~10 lines and avoids "almost structured-clone" surprise. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/codec/structured-clone.test.ts` — every `**Verified by:**` line in the spec delta, invoked via `runCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })` plus direct tests for `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`.
|
||||
- `src/codec/conformance.test.ts` — harness self-test: passing a deliberately broken codec causes the harness assertions to fail, proving the harness has teeth (no tautological green).
|
||||
- `src/codec/json.test.ts` and `src/codec/identity.test.ts` — extended with a `runCodecConformance(codec, { supportsStructuredClone: false })` call to prove the shared contract holds for the existing codecs at the JSON subset.
|
||||
- `src/errors/errors.test.ts` — extended to assert the two new codes (`CODEC_UNSUPPORTED_TYPE`, `CODEC_UNKNOWN_TAG`) are reachable from real call sites in `StructuredCloneCodec`.
|
||||
- `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100 retained.
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage` — green.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/cache-storage` to enumerate.
|
||||
|
||||
- `@suaveplan/cache-redis` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: consumes the `Codec<T>` interface from `@suaveplan/cache-storage/codec`; the new codec is interface-compatible and opt-in. No source change required; a follow-up README note can mention "pass `new StructuredCloneCodec()` if you want `Date`/`Map`/`Set` round-trips" but does not block this change.)
|
||||
- No other dependents at time of writing — verified by `bunx turbo ls --filter=...@suaveplan/cache-storage` returning only `cache-redis`.
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/cbor` — newly consumed via `workspace:^`. Not modified. [x] no action needed.
|
||||
- `@suaveplan/error` — `defineCodes` extended (additive); existing codes unchanged. [x] no action needed.
|
||||
- `@suaveplan/cache`, `@suaveplan/storage`, `@suaveplan/events`, `@suaveplan/types` — not touched.
|
||||
|
||||
## Migration
|
||||
|
||||
This is purely additive: no error renames, no default flips, no removed exports. Callers opt in by constructing `StorageCache({ storage, codec: new StructuredCloneCodec() })`. Existing callers using the default `JsonCodec` see byte-identical behaviour.
|
||||
@@ -0,0 +1,186 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: cache-storage (cache-storage)
|
||||
|
||||
**Package**: `@suaveplan/cache-storage`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: StructuredCloneCodec round-trips the structured-clone-ish JS subset
|
||||
|
||||
The package SHALL export `StructuredCloneCodec<T> implements Codec<T>` from `./codec/structured-clone`. The codec SHALL faithfully round-trip the following JS value types through `encode` then `decode`: `null`, `boolean`, finite `number`, `string`, plain object, array, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive and negative), `RegExp` (source only — flags excluded by tag-35 wire format), `URL`, and arbitrary nestings of the above. Encoding SHALL delegate to `encode` from `@suaveplan/cbor`; decoding SHALL delegate to `decode` from `@suaveplan/cbor` with a tag-handler registry that reverses the lift table.
|
||||
|
||||
#### Scenario: round-trip preserves Date instances
|
||||
|
||||
- **WHEN** a caller calls `codec.decode(codec.encode(new Date("2026-05-17T12:00:00.000Z")))`
|
||||
- **THEN** the result is `instanceof Date` AND its `.getTime()` equals `new Date("2026-05-17T12:00:00.000Z").getTime()`
|
||||
|
||||
#### Scenario: round-trip preserves Map with object keys and values
|
||||
|
||||
- **WHEN** a caller round-trips `new Map<unknown, unknown>([[{ id: 1 }, "a"], [{ id: 2 }, new Set([1, 2])]])`
|
||||
- **THEN** the result is `instanceof Map`, has `size === 2`, AND iterating yields keys/values deep-equal to the input (the second value being `instanceof Set` with members `1` and `2`)
|
||||
|
||||
#### Scenario: round-trip preserves Uint8Array byte identity
|
||||
|
||||
- **WHEN** a caller round-trips `new Uint8Array([0, 1, 127, 128, 255])`
|
||||
- **THEN** the result is `instanceof Uint8Array` AND every byte is equal to the input at the same index
|
||||
|
||||
#### Scenario: round-trip preserves bigint magnitude and sign
|
||||
|
||||
- **WHEN** a caller round-trips both `2n ** 200n` and `-(2n ** 200n) - 7n`
|
||||
- **THEN** each result is `typeof "bigint"` AND strictly equals the input
|
||||
|
||||
#### Scenario: deeply nested structures survive
|
||||
|
||||
- **WHEN** a caller round-trips `{ users: new Map([["alice", { tags: new Set(["admin"]), joined: new Date(0), avatar: new Uint8Array([1, 2, 3]) }]]) }`
|
||||
- **THEN** the result deep-equals the input across `Map`, `Set`, `Date`, and `Uint8Array` boundaries
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"round-trip preserves Date instances"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Map with object keys and values"` AND `src/codec/structured-clone.test.ts::"round-trip preserves Uint8Array byte identity"` AND `src/codec/structured-clone.test.ts::"round-trip preserves bigint magnitude and sign"` AND `src/codec/structured-clone.test.ts::"deeply nested structures survive"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: StructuredCloneCodec uses IANA-registered CBOR tags
|
||||
|
||||
The codec SHALL use the following CBOR tag numbers from the IANA CBOR Tags registry: `0` (RFC 3339 date-time string) for `Date`; `2` (positive bignum) for `bigint > 0n`; `3` (negative bignum) for `bigint < 0n`; `32` (URI) for `URL`; `35` (PCRE regex) for `RegExp`; `64` (`Uint8` typed array) for `Uint8Array`; `258` (Set) for `Set`; `259` (Map with explicit key/value pairs) for `Map`. The codec SHALL NOT invent private tag numbers for any carrier listed in the round-trip requirement above.
|
||||
|
||||
#### Scenario: encoded Date emits CBOR tag 0
|
||||
|
||||
- **WHEN** a caller calls `codec.encode(new Date("2026-05-17T12:00:00.000Z"))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
|
||||
- **THEN** the decoded top-level value is `instanceof Tagged` with `.tag === 0` AND `.value` is the RFC 3339 string
|
||||
|
||||
#### Scenario: encoded Uint8Array emits CBOR tag 64
|
||||
|
||||
- **WHEN** a caller calls `codec.encode(new Uint8Array([1, 2, 3]))` AND the result is fed back through `decode` from `@suaveplan/cbor` with default tag handling
|
||||
- **THEN** the decoded top-level value is `instanceof Tagged` with `.tag === 64`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"Date encodes under CBOR tag 0"` AND `src/codec/structured-clone.test.ts::"Uint8Array encodes under CBOR tag 64"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CODEC_UNSUPPORTED_TYPE on encode of unencodable carriers
|
||||
|
||||
`StructuredCloneCodec.encode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"` when the input contains (directly or transitively) any of: `Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, or any class instance whose constructor is not one of the registered carriers (`Date`, `Map`, `Set`, `Uint8Array`, `RegExp`, `URL`). The error SHALL carry the failing type name in its message or context.
|
||||
|
||||
#### Scenario: encoding a function throws CODEC_UNSUPPORTED_TYPE
|
||||
|
||||
- **WHEN** a caller calls `codec.encode((() => 1) as unknown)`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
|
||||
|
||||
#### Scenario: encoding a Symbol nested in an object throws CODEC_UNSUPPORTED_TYPE
|
||||
|
||||
- **WHEN** a caller calls `codec.encode({ ok: 1, bad: Symbol("x") } as unknown)`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a function"` AND `src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a nested Symbol"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CODEC_UNKNOWN_TAG on decode of an unregistered CBOR tag
|
||||
|
||||
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"` when the input CBOR bytes contain a `Tagged(n, …)` value whose tag `n` is not one of the registered tags (`0`, `2`, `3`, `32`, `35`, `64`, `258`, `259`). The error context SHALL carry the offending tag number. The codec SHALL NOT silently return the value as a `Tagged` wrapper.
|
||||
|
||||
#### Scenario: decoding an unrecognised tag throws CODEC_UNKNOWN_TAG
|
||||
|
||||
- **WHEN** a caller encodes `new Tagged(9999, "x")` via `encode` from `@suaveplan/cbor` AND passes the resulting bytes to `codec.decode`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_UNKNOWN_TAG on an unregistered tag"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CODEC_ERROR on malformed CBOR bytes
|
||||
|
||||
`StructuredCloneCodec.decode` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` when the input bytes are not valid CBOR (catching any `CborError` thrown by `decode` from `@suaveplan/cbor` and re-wrapping). The original `CborError` SHALL be attached as `cause`.
|
||||
|
||||
#### Scenario: decoding random bytes throws CODEC_ERROR with cbor cause
|
||||
|
||||
- **WHEN** a caller calls `codec.decode(new Uint8Array([0xff, 0xff, 0xff, 0xff]))` for bytes that do not form a valid CBOR item
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"` AND `error.cause` is the underlying `CborError`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"decode throws CODEC_ERROR with cbor cause on malformed bytes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: StructuredCloneCodec interoperates with StorageCache
|
||||
|
||||
`StructuredCloneCodec` SHALL be assignable to the `codec` constructor option of `StorageCache<T>` without any cast. A `StorageCache<T>` constructed with `codec: new StructuredCloneCodec<T>()` SHALL preserve every round-trip case from the first requirement when values are written via `cache.set` and read back via `cache.get`.
|
||||
|
||||
#### Scenario: StorageCache + StructuredCloneCodec round-trips a Map value
|
||||
|
||||
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage(), codec: new StructuredCloneCodec() })`, calls `cache.set("k", new Map([["a", 1]]))`, AND calls `cache.get("k")`
|
||||
- **THEN** the result is `instanceof Map` with `size === 1` AND `.get("a") === 1`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"StorageCache with StructuredCloneCodec round-trips a Map value"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Codec conformance harness exposed to package tests
|
||||
|
||||
The package SHALL export a `runCodecConformance(codec, options)` harness from `./codec/conformance` accepting `{ name: string; supportsStructuredClone: boolean }`. The harness SHALL execute round-trip assertions for every type listed in the first requirement; when `supportsStructuredClone` is `false`, the harness SHALL restrict assertions to types the JSON profile preserves (primitives, plain objects, arrays). The harness SHALL fail loudly when given a deliberately broken codec (proof of teeth — no tautological green).
|
||||
|
||||
#### Scenario: harness reports failure on a broken codec
|
||||
|
||||
- **WHEN** the test suite passes a codec whose `decode` returns a fixed sentinel `null` instead of the round-tripped value
|
||||
- **THEN** at least one harness assertion fails
|
||||
|
||||
#### Scenario: harness passes for the existing JsonCodec at the JSON profile
|
||||
|
||||
- **WHEN** the test suite calls `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", supportsStructuredClone: false })`
|
||||
- **THEN** every assertion passes
|
||||
|
||||
**Verified by:** `src/codec/conformance.test.ts::"harness fails on a deliberately broken codec"` AND `src/codec/json.test.ts::"JsonCodec satisfies the JSON-profile conformance harness"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CACHE_STORAGE registry extended with two codes
|
||||
|
||||
`CACHE_STORAGE_CODES` SHALL be extended via the same `defineCodes("CACHE_STORAGE", [...])` call to include `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`, bringing the registry to 7 codes total. Both new codes SHALL be reachable from at least one test in this package. The existing 5 codes (`ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`) SHALL remain unchanged.
|
||||
|
||||
#### Scenario: every CACHE_STORAGE code (including the two new ones) is reachable
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** for every entry in `CACHE_STORAGE_CODES`, at least one assertion exercises a real call site that produces that code
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"every CACHE_STORAGE code is reachable from a real call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Default codec for StorageCache remains JsonCodec
|
||||
|
||||
The constructor default for `StorageCache.codec` SHALL remain `new JsonCodec<T>()`. Existing callers that do not pass `codec` SHALL observe byte-identical behaviour to the codec they observed before this change. The new `StructuredCloneCodec` SHALL be opt-in by explicit construction only.
|
||||
|
||||
#### Scenario: StorageCache without explicit codec uses JsonCodec
|
||||
|
||||
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`, sets a string value, and inspects the bytes written by the underlying storage past `HEADER_BYTES`
|
||||
- **THEN** the bytes equal `new TextEncoder().encode(JSON.stringify(value))`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"default codec is JsonCodec — bytes past the header are UTF-8 JSON"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath export for the new codec
|
||||
|
||||
`package.json` SHALL expose `./codec/structured-clone` as a subpath export mirroring the existing `./codec/json` and `./codec/identity` entries, so tree-shaking consumers can import only the new codec without dragging in the rest of the codec barrel.
|
||||
|
||||
#### Scenario: subpath import resolves the StructuredCloneCodec class
|
||||
|
||||
- **WHEN** a consumer imports `import { StructuredCloneCodec } from "@suaveplan/cache-storage/codec/structured-clone"`
|
||||
- **THEN** the import resolves to the same class as `import { StructuredCloneCodec } from "@suaveplan/cache-storage/codec"`
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"./codec/structured-clone subpath resolves to the same class as ./codec"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tier purity — universal preserved
|
||||
|
||||
Adding `StructuredCloneCodec` SHALL NOT introduce any `node:*`, `bun:*`, or DOM-only import into the package. The package SHALL continue to run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
|
||||
|
||||
#### Scenario: tier-purity grep finds no forbidden imports after the change
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-storage/src/` is run
|
||||
- **THEN** the output is empty
|
||||
|
||||
**Verified by:** `src/codec/structured-clone.test.ts::"structured-clone codec contains no node: or bun: imports"`
|
||||
@@ -0,0 +1,60 @@
|
||||
# Tasks — add-cache-storage-cbor-codec
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Additive only — no behaviour change to existing codecs.
|
||||
|
||||
## 1. Pre-flight
|
||||
|
||||
- [ ] 1.1 Re-read `openspec/AGENTS.md` §0c (delta format) and §13 (gate order)
|
||||
- [ ] 1.2 Confirm `@suaveplan/cbor` ships on genesis with `encode`, `decode`, `Tagged`, `type TagHandler` in the root barrel
|
||||
- [ ] 1.3 Confirm `packages/flow/cache-storage/src/codec/codec.types.ts` exposes `Codec<T>` and that `storage-cache.ts` defaults to `JsonCodec` (no behaviour change needed)
|
||||
- [ ] 1.4 Numbered checklist of every `### Requirement` and `#### Scenario` in `specs/cache-storage/spec.md` exists in working notes (AGENTS §0c step 6)
|
||||
|
||||
## 2. Package wiring
|
||||
|
||||
- [ ] 2.1 Add `@suaveplan/cbor` to `packages/flow/cache-storage/package.json` `dependencies` at `workspace:^`
|
||||
- [ ] 2.2 Add subpath export `./codec/structured-clone` mirroring the shape of `./codec/json` and `./codec/identity`
|
||||
- [ ] 2.3 Confirm `sideEffects` array remains correct (no new side-effect file added)
|
||||
|
||||
## 3. Error registry extension
|
||||
|
||||
- [ ] 3.1 Extend `CACHE_STORAGE_CODES` in `src/errors/errors.ts` with `CODEC_UNSUPPORTED_TYPE` and `CODEC_UNKNOWN_TAG`
|
||||
- [ ] 3.2 Update `src/errors/errors.md` Features + API Reference to list the two new codes (doc-drift gate §4a)
|
||||
|
||||
## 4. Implementation — StructuredCloneCodec
|
||||
|
||||
- [ ] 4.1 `src/codec/structured-clone.ts` — `StructuredCloneCodec<T> implements Codec<T>`; lift table per `design.md`; encode/decode against `@suaveplan/cbor`
|
||||
- [ ] 4.2 `src/codec/structured-clone.md` — sibling docs (≥200 words, basic + advanced examples, API reference, implementation notes)
|
||||
- [ ] 4.3 `src/codec/index.ts` — explicit named re-export of `StructuredCloneCodec`
|
||||
- [ ] 4.4 Confirm file size ≤300 lines; split if needed (e.g. lift table to `src/codec/structured-clone.tags.ts`)
|
||||
|
||||
## 5. Conformance harness
|
||||
|
||||
- [ ] 5.1 `src/codec/conformance.ts` — `runCodecConformance(codec, { name, supportsStructuredClone })` covering primitives, nested objects, arrays, `Date`, `Map`, `Set`, `Uint8Array`, `bigint` (positive + negative), `RegExp`, `URL`, deep nesting, empty collections
|
||||
- [ ] 5.2 `src/codec/conformance.md` — sibling docs
|
||||
- [ ] 5.3 `src/codec/conformance.test.ts` — harness self-test: a deliberately broken `Codec<unknown>` must cause the harness to fail (no tautological green)
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [ ] 6.1 `src/codec/structured-clone.test.ts` — invoke `runCodecConformance(new StructuredCloneCodec(), { name: "StructuredCloneCodec", supportsStructuredClone: true })`; plus direct tests for `CODEC_UNSUPPORTED_TYPE` (encode `() => {}`, `Symbol("x")`, `new WeakMap()`) and `CODEC_UNKNOWN_TAG` (decode a CBOR-encoded `Tagged(9999, "x")`)
|
||||
- [ ] 6.2 Extend `src/codec/json.test.ts` with `runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", supportsStructuredClone: false })`
|
||||
- [ ] 6.3 Extend `src/codec/identity.test.ts` with `runCodecConformance(new IdentityCodec(), { name: "IdentityCodec", supportsStructuredClone: false })` — using `Uint8Array` payloads only
|
||||
- [ ] 6.4 Extend `src/errors/errors.test.ts` to assert the two new codes are reachable
|
||||
- [ ] 6.5 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [ ] 6.6 All test imports via `@suaveplan/testing/runner` (§7)
|
||||
|
||||
## 7. Gates (§13 order)
|
||||
|
||||
- [ ] 7.1 Spec reconciliation — walk every `### Requirement` / `#### Scenario` against impl
|
||||
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/cache-storage`
|
||||
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/cache-storage` — 100/100/100/100
|
||||
- [ ] 7.4 `bunx turbo build --filter=@suaveplan/cache-storage`
|
||||
- [ ] 7.5 Banned-pattern, barrel-shape, runner-import, missing-`.md`, bare-version scans (zero output)
|
||||
- [ ] 7.6 `bunx turbo test --filter=...@suaveplan/cache-storage` — dependent sweep (`@suaveplan/cache-redis` must remain green)
|
||||
|
||||
## 8. Manifest & archival
|
||||
|
||||
- [ ] 8.1 `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 8.2 Update `packages/flow/cache-storage/README.md` Modules section with the new codec
|
||||
- [ ] 8.3 `bunx markdownlint-cli2 --fix`
|
||||
- [ ] 8.4 Fold `specs/cache-storage/spec.md` into `openspec/specs/cache-storage/spec.md` (merge `## ADDED` block into existing spec)
|
||||
- [ ] 8.5 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-cache-storage-cbor-codec/`
|
||||
@@ -0,0 +1,164 @@
|
||||
# Design — add-drive-range-reads
|
||||
|
||||
> Non-normative. Normative requirements live in `./specs/drive/spec.md` and `./specs/drive-uri/spec.md`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Extend `Drive.createReadStream` with `{ offset, length }` without breaking the bare-`AbortSignal` signature used at every existing call site.
|
||||
2. Push the range hint through the plugin middleware so `drive-plugin-chunks` only fetches/decrypts/emits chunks intersecting the range.
|
||||
3. Over `drive+mesh://`, request only the in-range chunkIds from peers — `ChunkRequest.specific` for small windows, `ChunkRequest.bitset` for large contiguous spans.
|
||||
4. Surface range failures via two new `DRIVE_*` codes; reuse existing wire format and pass-through semantics elsewhere.
|
||||
|
||||
## API surface evolution
|
||||
|
||||
### `Drive.createReadStream` — overloaded
|
||||
|
||||
```ts
|
||||
interface RangeReadOptions {
|
||||
offset?: number; // default 0; integer ≥ 0
|
||||
length?: number; // default → EOF; integer ≥ 0
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
class Drive {
|
||||
// Existing (preserved)
|
||||
createReadStream(path: string, signal?: AbortSignal): ReadableStream<Uint8Array>;
|
||||
// Added
|
||||
createReadStream(path: string, options: RangeReadOptions): ReadableStream<Uint8Array>;
|
||||
// Implementation
|
||||
createReadStream(
|
||||
path: string,
|
||||
signalOrOptions?: AbortSignal | RangeReadOptions,
|
||||
): ReadableStream<Uint8Array> { … }
|
||||
}
|
||||
```
|
||||
|
||||
Detection: `signalOrOptions instanceof AbortSignal ? { signal: signalOrOptions } : (signalOrOptions ?? {})`. The runtime `instanceof` check is reliable in every supported runtime (Bun, Node 22+, Chromium 102+, Safari 16+) and avoids a brittle duck-type sniff.
|
||||
|
||||
Validation: synchronous — when `offset` or `length` is present, it MUST be a finite non-negative integer (`Number.isInteger(x) && x >= 0`). On failure, throw `DriveError(DRIVE.RANGE_INVALID, "offset must be a non-negative integer")` BEFORE constructing the stream.
|
||||
|
||||
### `StorageUriResolver` contract
|
||||
|
||||
`@suaveplan/storage-uri`'s `UriPlugin.adapter.createReadStream(key, options?)` already accepts an options bag; we extend the contract to include `{ offset?, length? }` per the `StorageLike` evolution path in `add-signal-to-storage-like` (archived). The resolver passes the bag through unchanged. `drive-uri`'s three plugins each set `adapter.capabilities.nativeRange = true` so the resolver issues one range-aware `createReadStream` call instead of buffering and slicing.
|
||||
|
||||
## Range → chunk math
|
||||
|
||||
Given:
|
||||
|
||||
- `chunkSize` (uint32, from `manifest.chunkSize` for chunks-plugin; from `storage.chunkSize` otherwise; conventionally 65536)
|
||||
- `fileSize` (bigint, from `manifest.fileSize`)
|
||||
- requested `offset` (default 0) and `length` (default `fileSize - offset`)
|
||||
|
||||
Compute:
|
||||
|
||||
- `end = min(offset + length, fileSize)` // exclusive
|
||||
- `firstChunk = floor(offset / chunkSize)`
|
||||
- `lastChunk = floor((end - 1) / chunkSize)` // inclusive; only when `end > offset`
|
||||
- `firstChunkSkip = offset - firstChunk * chunkSize` // bytes to drop from the front of `firstChunk`
|
||||
- `lastChunkKeep = end - lastChunk * chunkSize` // bytes to retain from the front of `lastChunk`
|
||||
|
||||
Emit:
|
||||
|
||||
- For `firstChunk`: decrypt, slice `[firstChunkSkip, min(chunkSize, lastChunkKeep))` if `firstChunk === lastChunk`, else `[firstChunkSkip, payloadLen)`
|
||||
- For interior chunks `(firstChunk, lastChunk)`: emit whole `chunk.slice(0, payloadLen)`
|
||||
- For `lastChunk` (when distinct from `firstChunk`): decrypt, slice `[0, lastChunkKeep)`
|
||||
|
||||
Zero-length: when `length === 0`, do not enter the loop — emit an empty `Uint8Array` and close.
|
||||
|
||||
### Worked examples (chunkSize = 65 536; last chunk holds `payloadLen` bytes only)
|
||||
|
||||
| Scenario | offset | length | end | firstChunk | lastChunk | firstSkip | lastKeep | Chunks pulled |
|
||||
|--------------------------------|---------|---------|-----------|------------|-----------|-----------|----------|---------------|
|
||||
| Range fully inside one chunk | 1 024 | 2 048 | 3 072 | 0 | 0 | 1 024 | 3 072 | {0} (1) |
|
||||
| Spans one boundary | 60 000 | 10 000 | 70 000 | 0 | 1 | 60 000 | 4 464 | {0, 1} (2) |
|
||||
| Aligned multi-chunk | 65 536 | 196 608 | 262 144 | 1 | 3 | 0 | 65 536 | {1, 2, 3} (3) |
|
||||
| Tail to EOF (fileSize=200 000) | 150 000 | — | 200 000 | 2 | 3 | 18 928 | 3 392 | {2, 3} (2) |
|
||||
| Zero-length | 12 345 | 0 | 12 345 | n/a | n/a | n/a | n/a | ∅ (0) |
|
||||
| Whole file | 0 | — | fileSize | 0 | last | 0 | tailLen | {0..last} (N) |
|
||||
|
||||
## Mesh-request optimisation
|
||||
|
||||
The mesh pull path today issues `ChunkRequest.whole`. With a range, build:
|
||||
|
||||
```ts
|
||||
const span = lastChunk - firstChunk + 1;
|
||||
const req: ChunkRequest =
|
||||
span <= 64
|
||||
? { kind: "specific", chunkIds: rangeArray(firstChunk, lastChunk) }
|
||||
: { kind: "bitset", bytes: bitsetForRange(firstChunk, lastChunk, totalChunks) };
|
||||
```
|
||||
|
||||
Pivot rationale:
|
||||
|
||||
- `MAX_CHUNKS_DEFAULT = 64` (`drive-mesh-protocol/src/schemas/envelope.schema.ts`). JSON `chunkIds` for 64 numbers ~ 200 B; a bitset for the same window is ~ 8 B header + ⌈64/8⌉ = 16 B = under 30 B framed. At 64+ chunks, the bitset is uniformly cheaper.
|
||||
- The bitset is constructed via `encodeBitset` from `drive-mesh-protocol`; the receiver decode path is unchanged.
|
||||
- `ParallelPuller`'s window applies to whichever chunkIds the request enumerated, so range simply narrows the parallelism domain.
|
||||
|
||||
## Abort handling
|
||||
|
||||
The `AbortSignal` (from `RangeReadOptions.signal` OR the bare-signal form) is threaded into:
|
||||
|
||||
1. `storage.createReadStream(path, { signal })` when the storage has `nativeStreams`.
|
||||
2. The chunks-plugin `#reassembleAsStream` loop — each `layout.getChunk(fileId, i)` call is wrapped by `throwIfAborted(signal)` between iterations.
|
||||
3. The mesh pull — `drive-mesh.ParallelPuller` already accepts a signal; the range path passes it through unchanged.
|
||||
|
||||
On abort: cancel in-flight chunk fetches, close the stream with `controller.error(new DOMException("aborted", "AbortError"))`, and (over mesh) emit a single `cd:error` envelope with `code = "ABORTED"` so peers can free reservation slots.
|
||||
|
||||
## Error codes table
|
||||
|
||||
| Code | Origin | When raised |
|
||||
|-------------------------------|---------------------------------|--------------------------------------------------------------------------------------------------------------|
|
||||
| `DRIVE.RANGE_INVALID` | `Drive.createReadStream` | `offset`/`length` is non-integer / negative / NaN / not finite. Thrown synchronously before stream creation. |
|
||||
| `DRIVE.RANGE_OUT_OF_BOUNDS` | `Drive.createReadStream` OR stream `error` event | Synchronously: `offset > size` or `offset + length > size` when `size` is known at start. Asynchronously: short-fall on stream close when size was unknown at start. |
|
||||
| `CHUNKS.RANGE_OUT_OF_BOUNDS` | `chunksPlugin` middleware | Manifest `fileSize` is loaded and the range exceeds it. Surfaced on stream `error`; the drive layer re-wraps as `DRIVE.RANGE_OUT_OF_BOUNDS` with `cause`. |
|
||||
|
||||
No new codes in `DRIVE_URI`, `DMP`, or `DTP`. The resolver pass-through rule already forwards `DRIVE.*` codes to the caller intact.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/drive/drive/src/
|
||||
file-operations/
|
||||
streams.ts # MODIFIED — overload signatures, range slicing transform
|
||||
streams.test.ts # MODIFIED — new range cases
|
||||
streams.range.test.ts # ADDED — boundary cases (table above)
|
||||
streams.range.md # ADDED — docs for the new path
|
||||
types/
|
||||
drive.types.ts # MODIFIED — RangeReadOptions, DriveStorage.capabilities.nativeRange
|
||||
error-codes.ts # MODIFIED — RANGE_INVALID, RANGE_OUT_OF_BOUNDS
|
||||
|
||||
packages/drive/drive-plugin-chunks/src/
|
||||
pipeline/
|
||||
range.ts # ADDED — pure range→chunk math (table above)
|
||||
range.test.ts # ADDED — property tests over (offset, length, fileSize, chunkSize)
|
||||
range.md # ADDED
|
||||
plugin.ts # MODIFIED — #reassemble takes range?, #reassembleAsStream
|
||||
error-codes.ts # MODIFIED — RANGE_OUT_OF_BOUNDS
|
||||
|
||||
packages/drive/drive-mesh/src/
|
||||
pull/
|
||||
range-request.ts # ADDED — specific-vs-bitset pivot
|
||||
range-request.test.ts # ADDED
|
||||
range-request.md # ADDED
|
||||
parallel-puller.ts # MODIFIED — accept range, build request via range-request
|
||||
|
||||
packages/drive/drive-uri/src/
|
||||
adapter/
|
||||
drive-storage-like.ts # MODIFIED — forward { offset, length } from createReadStream options
|
||||
drive-storage-like.test.ts # MODIFIED — range forwarding
|
||||
plugins/
|
||||
drive/drive.streams.ts # MODIFIED — InstrumentedDriveAdapter.createReadStream forwards range
|
||||
drive/drive.streams.test.ts# MODIFIED — range + AbortSignal
|
||||
otel/
|
||||
otel.ts # MODIFIED — drive.uri.range attribute, range_chunks_fetched histogram
|
||||
otel.test.ts # MODIFIED
|
||||
```
|
||||
|
||||
Every new `.ts` carries a sibling `.md` per CLAUDE.md §4; every new test uses `@suaveplan/testing/runner` per rule 11.
|
||||
|
||||
## Trade-offs considered
|
||||
|
||||
- **`{ offset, length }` vs. `{ start, end }`.** Picked `offset`/`length` to match the HTTP `Range: bytes=offset-(offset+length-1)` mental model and avoid the inclusive-vs-exclusive `end` ambiguity that bites every range API at least once.
|
||||
- **Always-bitset vs. always-specific request.** Always-bitset wastes ~ 20 bytes on a 3-chunk range; always-specific blows the `MAX_CHUNKS_DEFAULT` cap on multi-MiB ranges. Pivoting at the cap is honest about the wire's own constraint.
|
||||
- **`Drive.read({ offset, length })` companion.** Tempting but redundant: `await new Response(stream).arrayBuffer()` reduces to the same thing. Defer to a hoist when a concrete consumer asks.
|
||||
- **Throw vs. clamp on overflow.** Clamping (HTTP `Range: bytes=900-2000` on a 1 000-byte file silently returning bytes 900–999) hides upstream bugs. Throwing `RANGE_OUT_OF_BOUNDS` is louder and matches the rest of `DRIVE_*`'s explicit failure style.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Add byte-range reads to Drive.createReadStream and `drive*://` URI plugins
|
||||
|
||||
**Packages**: `@suaveplan/drive`, `@suaveplan/drive-uri`
|
||||
**Status**: PROPOSED
|
||||
**Type**: capability-extension
|
||||
**Defers from**: `openspec/archive/2026-05-16-add-drive-uri/proposal.md` § "Out of scope → Range reads on `drive+mesh://`"
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec deltas**:
|
||||
- ./specs/drive/spec.md
|
||||
- ./specs/drive-uri/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`add-drive-uri` shipped `resolver.read("drive+mesh://swarm-x/clip.mov")` as a whole-file fetch only, explicitly carving out byte-range reads:
|
||||
|
||||
> **Range reads on `drive+mesh://`.** `drive.createReadStream` already streams chunk-by-chunk via the chunks plugin; byte-range slicing on top requires a future `Drive.createReadStream({ offset, length })` extension — tracked separately.
|
||||
|
||||
Range reads are the single most-asked-for missing capability for the drive surface — every consumer that wants HTTP `Range:` header support (audio/video seek, virtual scrolling over chunked logs, partial-image decode, resume-on-reconnect, S3-style multipart staging) is currently forced to fetch the full file and slice client-side. Over `drive+mesh://` this is catastrophic: a 4 GiB media file pulled to seek the last 1 MiB downloads the entire swarm payload. The chunked layout in `drive-plugin-chunks` already addresses every chunk by `(fileId, chunkId)` with a known fixed `chunkSize` per manifest, so range → chunk math is deterministic; the missing piece is plumbing the offset/length pair from `Drive.createReadStream` through the chunks-plugin middleware so only the chunks intersecting the range are fetched, decrypted, and emitted.
|
||||
|
||||
This change adds the `{ offset, length }` options to `Drive.createReadStream`, extends the chunks plugin's `#reassemble` to honour them (computing first/last chunk indices, slicing the boundary chunks), threads range hints through the `drive-uri` adapters, and — over `drive+mesh://` — emits a `ChunkRequest.specific` (or `bitset` for large contiguous ranges) listing only the in-range chunkIds rather than `ChunkRequest.whole`. The local-storage and template paths get the same offset/length math for free because they go through the same chunks-plugin middleware.
|
||||
|
||||
## What changes
|
||||
|
||||
### `@suaveplan/drive`
|
||||
|
||||
- MODIFIED: `Drive.createReadStream(path, signal?)` → `Drive.createReadStream(path, options?: RangeReadOptions | AbortSignal)`. The new positional-options overload accepts `{ offset?: number; length?: number; signal?: AbortSignal }`. The bare-`AbortSignal` form is preserved via overload so all existing call sites compile unchanged.
|
||||
- ADDED: `RangeReadOptions` interface in `src/types/drive.types.ts` — `offset` defaults to `0`, `length` defaults to "to EOF". Both MUST be non-negative integers when present; violations raise `DRIVE.RANGE_INVALID` synchronously (before stream construction).
|
||||
- ADDED: `DRIVE.RANGE_INVALID` and `DRIVE.RANGE_OUT_OF_BOUNDS` codes in `DRIVE_CODES`. `RANGE_INVALID` covers negative / non-integer / NaN inputs. `RANGE_OUT_OF_BOUNDS` covers `offset > size` and `offset + length > size` where `size` is known at stream-start (e.g. via the chunks-plugin manifest); when size is unknown at start-time, the stream end-event MUST surface `RANGE_OUT_OF_BOUNDS` if the underlying source closed before `length` bytes were emitted.
|
||||
- MODIFIED: `defaultCreateReadStream` and the storage-capability passthrough in `src/file-operations/streams.ts` honour `RangeReadOptions`. The default buffered path slices the loaded `Uint8Array`; the native-streams path forwards `{ offset, length }` to `storage.createReadStream` when `storage.capabilities?.nativeRange === true`, otherwise wraps the native stream in a slicing transform that drops bytes before `offset` and stops after `length`.
|
||||
- ADDED: `DriveStorage` capability flag `capabilities.nativeRange?: boolean` and an extended `createReadStream(path, options?: { offset?: number; length?: number; signal?: AbortSignal })` signature on the storage layer. Storages that already accept a range (e.g. file with `fs.createReadStream({ start, end })`) opt in by setting the flag; existing storages are unaffected.
|
||||
- ADDED: Middleware context carries `range?: { offset: number; length?: number }`. The chunks-plugin middleware reads it and short-circuits its `#reassemble` to the intersecting chunk window.
|
||||
|
||||
### `@suaveplan/drive-plugin-chunks`
|
||||
|
||||
- MODIFIED: `ChunksPluginHandle.#reassemble(fileId, range?)` accepts an optional range and emits only the chunks intersecting `[offset, offset + length)`. First and last chunks are sliced; interior chunks are emitted whole. Algorithm in `design.md` § "Range → chunk math".
|
||||
- MODIFIED: The `read` middleware forwards `ctx.range` (when present) to `#reassemble` and to a sibling `#reassembleAsStream` used by `createReadStream`. The middleware's `read(path)` buffered call buffers only the in-range bytes.
|
||||
- ADDED: `CHUNKS.RANGE_OUT_OF_BOUNDS` code — raised when the requested range exceeds `manifest.fileSize`. Surfaced on stream `error`.
|
||||
|
||||
### `@suaveplan/drive-mesh-protocol`
|
||||
|
||||
- MODIFIED: `drive-mesh`'s pull path (consumer of this package; no protocol wire change) constructs `ChunkRequest.specific { chunkIds }` for ranges spanning ≤ 64 chunks and `ChunkRequest.bitset { bytes }` for larger contiguous spans. No new wire fields — both forms already exist in `ChunkRequestPayload`.
|
||||
- No `DMP_*` registry changes.
|
||||
|
||||
### `@suaveplan/drive-uri`
|
||||
|
||||
- MODIFIED: `DriveStorageLikeAdapter.createReadStream(key, options?)` accepts and forwards `{ offset?, length?, signal? }`. The three plugins (`driveUriPlugin`, `driveMeshUriPlugin`, `driveTemplateUriPlugin`) advertise `capabilities.nativeRange = true` on their adapter so `StorageUriResolver` issues a single range-aware `createReadStream` call instead of a whole-file `get` + client-side slice.
|
||||
- ADDED: `DRIVE_URI.RANGE_UNSUPPORTED` is **not** introduced — every `drive*://` URI now supports ranges because the underlying chunks plugin does. The resolver surfaces upstream `DRIVE.RANGE_INVALID` / `DRIVE.RANGE_OUT_OF_BOUNDS` directly under the existing pass-through rule.
|
||||
- ADDED: OTel attribute `drive.uri.range = "{offset}-{end}"` on `createReadStream` spans (omitted when no range supplied). Histogram `drive_uri.range_chunks_fetched` tags `{scheme}` and records the count of chunks actually pulled — measurable savings vs. whole-file fetch.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Multi-range reads** (RFC 7233 `multipart/byteranges`). One range per call. A caller wanting N disjoint ranges issues N `createReadStream` calls.
|
||||
- **`Drive.read(path, { offset, length })`** buffered range read. Achievable today via `createReadStream({ offset, length })` + buffer-collect; adding it to the buffered path would duplicate the chunk-math without changing observable behaviour. Hoist if a real consumer needs it.
|
||||
- **Range writes / partial uploads.** Writes remain whole-file. Chunk-level patch semantics belong in a future `add-drive-patch` change.
|
||||
- **Range hints on `drive`-tier `read()`** for non-chunked storages without `nativeRange`. Falls back to the slicing transform; performance characteristics are documented but no new code path is added.
|
||||
- **Backpressure-aware peer scheduling** for large bitset requests. The mesh plugin's existing `ParallelPuller` window applies unchanged; range simply narrows the chunk set it pulls from.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This is a **genesis-original** extension; deferral source is `openspec/archive/2026-05-16-add-drive-uri/proposal.md`. Surface anchors:
|
||||
|
||||
- `Drive.createReadStream` — `packages/drive/drive/src/drive/drive.ts:222-230`, `packages/drive/drive/src/file-operations/streams.ts`.
|
||||
- Chunks plugin `#reassemble` — `packages/drive/drive-plugin-chunks/src/plugin.ts:218-251`; chunkSize lives on the manifest (`packages/drive/drive-plugin-chunks/src/manifest/build.ts:11,30,74`), default 65536 (`packages/drive/drive-plugin-chunks/src/plugin.ts:78`).
|
||||
- `ChunkRequest` union — `packages/drive/drive-mesh-protocol/src/types/protocol.types.ts:38-42`; both `specific` and `bitset` already encodable.
|
||||
- `drive-uri` adapter — `packages/drive/drive-uri/src/plugins/drive-mesh/drive-mesh.ts` extends `InstrumentedDriveAdapter`.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Overload `createReadStream(path, signal)` AND `createReadStream(path, options)` rather than breaking the single-signal form | Every existing consumer in the monorepo (chunks plugin, drive-uri adapters, drive integration tests) passes the bare `AbortSignal`. A breaking signature change for an additive feature is unjustified when overloads cost nothing. |
|
||||
| Switch `ChunkRequest.specific` ↔ `ChunkRequest.bitset` at 64 chunks | `MAX_CHUNKS_DEFAULT = 64` in `drive-mesh-protocol`; beyond that, the JSON `chunkIds` array out-weighs a bitset that addresses every chunk in two bytes per 16. The 64-chunk pivot matches the wire-cap and minimises bytes-on-wire for the common case (4 MiB range over 64 KiB chunks). |
|
||||
| `RANGE_OUT_OF_BOUNDS` may fire at start OR at stream-end | When `size` is known (chunks-plugin manifest loaded, storage `stat` populated), reject synchronously. When unknown (raw `defaultCreateReadStream` over a non-stat-able storage), accept the range, emit what's available, then surface the code on `close` if shortfall is detected. Two paths because the size-known/size-unknown split is a real storage capability variance, not a defect. |
|
||||
| Zero-length range is a valid request | `length: 0` returns an immediately-closed empty stream. Useful as a probe-for-existence pattern; rejecting it would surprise consumers who compute ranges from upstream API responses. |
|
||||
| `nativeRange` storage capability flag rather than always-slicing in drive | `bun:sqlite`-backed and `fs`-backed storages can deliver true range reads with no read-amplification. Always wrapping in a slicing transform would discard that win for the storages that have it. The flag is opt-in so adding it is purely additive on every existing storage. |
|
||||
|
||||
## Migration
|
||||
|
||||
This is a purely additive change for every existing call site:
|
||||
|
||||
- **Old**: `drive.createReadStream("file.bin", abortSignal)` → still compiles, still returns whole-file stream.
|
||||
- **New**: `drive.createReadStream("file.bin", { offset: 1024, length: 4096, signal: abortSignal })` → emits exactly bytes [1024, 5120). Over `drive+mesh://`, requests at most ⌈(4096 + 1024 % chunkSize) / chunkSize⌉ chunks from peers.
|
||||
|
||||
No consumer must change. Consumers that want range behaviour migrate at their leisure. Search command for opt-in adoption:
|
||||
|
||||
```bash
|
||||
grep -rn "createReadStream" packages/ | grep -v "/dist/\|/coverage/"
|
||||
```
|
||||
|
||||
Recommended replacement when range-aware: pass `{ offset, length, signal }` instead of the bare `signal`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/drive` to enumerate. The blast radius is "every package that calls `Drive.createReadStream`," which today is:
|
||||
|
||||
- `@suaveplan/drive-uri` — [x] tests run / [x] docs reviewed / [ ] no action needed (adapter `createReadStream` extended to forward range; new requirement added to spec delta)
|
||||
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed / [ ] no action needed (middleware honours range; `#reassemble` extended)
|
||||
- `@suaveplan/drive-mesh` — [x] tests run / [x] docs reviewed / [ ] no action needed (pull path constructs `ChunkRequest.specific` / `bitset` from range)
|
||||
- `@suaveplan/drive-transport-https` — [ ] tests run / [x] docs reviewed / [x] no action needed (transport carries chunk envelopes opaquely; no surface change)
|
||||
- `@suaveplan/drive-transport-webrtc` — [ ] tests run / [x] docs reviewed / [x] no action needed (same as above)
|
||||
- `@suaveplan/drive-template` — [ ] tests run / [x] docs reviewed / [x] no action needed (hydration unchanged; range applies post-hydration)
|
||||
- `@suaveplan/drive-sharing` — [ ] tests run / [x] docs reviewed / [x] no action needed (calls `Drive.read`, not `createReadStream`)
|
||||
- `@suaveplan/drive-webrtc` — [ ] tests run / [x] docs reviewed / [x] no action needed (browser-tier transport, unaffected)
|
||||
|
||||
### Affected upstreams (none modified)
|
||||
|
||||
| Package | Action | Breaking? |
|
||||
|---|---|---|
|
||||
| `@suaveplan/storage` | Optional: extend `AbstractStorage.createReadStream` with `{ offset, length }` options. Existing storages opt in via `capabilities.nativeRange`. | no |
|
||||
| `@suaveplan/storage-uri` | None — already routes `createReadStream` through `UriPlugin.adapter`. | no |
|
||||
| `@suaveplan/types` | None — `RangeReadOptions` lives in `@suaveplan/drive` until a second consumer emerges. | no |
|
||||
|
||||
### Ordering
|
||||
|
||||
Unblocked. All packages exist on genesis today; the change is additive.
|
||||
@@ -0,0 +1,121 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: drive (drive)
|
||||
|
||||
**Package**: `@suaveplan/drive`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. The named tests are planned; they MUST exist and pass verbatim before this delta is folded into `openspec/specs/drive/spec.md`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `Drive.createReadStream` accepts a range-options bag
|
||||
|
||||
`Drive.createReadStream` SHALL be overloaded to accept either a bare `AbortSignal` (existing form) or a `RangeReadOptions` bag with optional `offset`, `length`, and `signal`. The bare-signal form MUST continue to return a whole-file stream. When the options form is passed, the drive MUST forward `{ offset, length, signal }` through the plugin middleware chain via the middleware context's `range` field. The implementation MUST runtime-discriminate via `instanceof AbortSignal`.
|
||||
|
||||
#### Scenario: bare signal still returns whole-file stream
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", abortSignal)` is called on a 200 KiB file
|
||||
- **THEN** the returned stream emits the entire 200 KiB payload and respects `abortSignal` for cancellation
|
||||
|
||||
#### Scenario: options form forwards offset and length
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { offset: 1024, length: 4096 })` is called
|
||||
- **THEN** the middleware context carries `range = { offset: 1024, length: 4096 }` and the returned stream emits exactly bytes `[1024, 5120)` of the source
|
||||
|
||||
#### Scenario: zero-length range yields an empty stream
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { offset: 12345, length: 0 })` is called
|
||||
- **THEN** the returned stream closes immediately with zero enqueued bytes
|
||||
|
||||
**Verified by:** `src/drive/drive.test.ts::"createReadStream(path, signal) preserves whole-file behaviour"` AND `src/drive/drive.test.ts::"createReadStream(path, { offset, length }) emits exactly the requested slice"` AND `src/drive/drive.test.ts::"createReadStream(path, { length: 0 }) returns an immediately closed stream"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Invalid range options throw `DRIVE.RANGE_INVALID` synchronously
|
||||
|
||||
`Drive.createReadStream(path, options)` SHALL validate `options.offset` and `options.length` synchronously before constructing the `ReadableStream`. Either field, when present, MUST be a finite non-negative integer (`Number.isInteger(x) && x >= 0 && Number.isFinite(x)`). On violation, the method MUST throw `DriveError(DRIVE.RANGE_INVALID, ...)` BEFORE returning. The error's `context` SHALL include the offending field name and value.
|
||||
|
||||
#### Scenario: negative offset throws synchronously
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { offset: -1 })` is called
|
||||
- **THEN** the call throws `DriveError` with `code === "DRIVE.RANGE_INVALID"` and `context.field === "offset"`, and no `ReadableStream` is created
|
||||
|
||||
#### Scenario: non-integer length throws synchronously
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { length: 1.5 })` is called
|
||||
- **THEN** the call throws `DriveError` with `code === "DRIVE.RANGE_INVALID"` and `context.field === "length"`
|
||||
|
||||
#### Scenario: NaN / Infinity rejected
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { offset: Number.NaN })` is called OR `drive.createReadStream("file.bin", { length: Number.POSITIVE_INFINITY })` is called
|
||||
- **THEN** each call throws `DriveError(DRIVE.RANGE_INVALID, ...)` synchronously
|
||||
|
||||
**Verified by:** `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for negative offset"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for non-integer length"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_INVALID for NaN or Infinity"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Out-of-bounds range surfaces `DRIVE.RANGE_OUT_OF_BOUNDS`
|
||||
|
||||
When the underlying source exposes a known size at stream-start (via the chunks-plugin manifest, a storage `stat`, or a `nativeRange`-capable storage that reports `size`), `Drive.createReadStream` MUST raise `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously for `offset > size` or `offset + length > size`. When size is unknown at start-time, the stream MUST accept the request, emit available bytes, and on close MUST surface `DRIVE.RANGE_OUT_OF_BOUNDS` via `controller.error` if the byte count emitted is less than the requested `length`. The error MUST carry the originating `CHUNKS.RANGE_OUT_OF_BOUNDS` (when applicable) as `cause`.
|
||||
|
||||
#### Scenario: offset past EOF rejected synchronously when size is known
|
||||
|
||||
- **WHEN** the underlying file is 1024 bytes and `drive.createReadStream("file.bin", { offset: 2048 })` is called via a chunks-plugin-mediated path
|
||||
- **THEN** the call throws `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously with `cause` carrying `CHUNKS.RANGE_OUT_OF_BOUNDS`
|
||||
|
||||
#### Scenario: offset+length past EOF rejected synchronously when size is known
|
||||
|
||||
- **WHEN** the file is 1024 bytes and `drive.createReadStream("file.bin", { offset: 900, length: 500 })` is called via the chunks plugin
|
||||
- **THEN** the call throws `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` synchronously
|
||||
|
||||
#### Scenario: short-fall on stream end when size was unknown at start
|
||||
|
||||
- **WHEN** the storage cannot report size, `drive.createReadStream("file.bin", { offset: 0, length: 10_000 })` is called, and the source closes after 4000 bytes
|
||||
- **THEN** the stream errors with `DriveError(DRIVE.RANGE_OUT_OF_BOUNDS, ...)` and `context.emittedBytes === 4000`
|
||||
|
||||
**Verified by:** `src/drive/drive.test.ts::"createReadStream throws RANGE_OUT_OF_BOUNDS when offset exceeds known size"` AND `src/drive/drive.test.ts::"createReadStream throws RANGE_OUT_OF_BOUNDS when offset+length exceeds known size"` AND `src/file-operations/streams.range.test.ts::"stream errors with RANGE_OUT_OF_BOUNDS on short-fall when size is unknown"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `nativeRange` storage capability is honoured
|
||||
|
||||
`DriveStorage` SHALL accept an optional `capabilities.nativeRange?: boolean` flag. When `true`, `Drive.createReadStream(path, { offset, length, signal })` MUST forward `{ offset, length }` to `storage.createReadStream` directly. When `false` (or absent), the drive MUST wrap the storage stream in a slicing transform that drops bytes before `offset` and stops emitting after `length` bytes. Both paths MUST produce identical observable output.
|
||||
|
||||
#### Scenario: storage with nativeRange receives offset/length
|
||||
|
||||
- **WHEN** `storage.capabilities.nativeRange === true` and `drive.createReadStream("file.bin", { offset: 1024, length: 4096 })` is invoked
|
||||
- **THEN** `storage.createReadStream` is called exactly once with `{ offset: 1024, length: 4096, signal }` and no slicing transform is interposed
|
||||
|
||||
#### Scenario: storage without nativeRange is wrapped in slicing transform
|
||||
|
||||
- **WHEN** `storage.capabilities?.nativeRange` is `undefined` or `false` and the same call is made
|
||||
- **THEN** `storage.createReadStream` is called with `{ signal }` only, and the returned stream is wrapped so the consumer observes exactly bytes `[1024, 5120)`
|
||||
|
||||
**Verified by:** `src/file-operations/streams.range.test.ts::"nativeRange storage receives offset and length directly"` AND `src/file-operations/streams.range.test.ts::"non-nativeRange storage is wrapped in a slicing transform"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Range cancellation propagates via `AbortSignal`
|
||||
|
||||
`Drive.createReadStream(path, options)` MUST propagate `options.signal` (or the bare signal in the back-compat form) through the chunks-plugin / mesh / storage pull chain. On abort, in-flight chunk fetches MUST be cancelled, the stream MUST close with `controller.error(AbortError)`, and (over `drive+mesh://`) the mesh transport MUST emit a `cd:error` envelope with `code: "ABORTED"`.
|
||||
|
||||
#### Scenario: mid-stream abort cancels chunk fetches
|
||||
|
||||
- **WHEN** `drive.createReadStream("file.bin", { offset: 0, length: 1_048_576, signal })` is called and `signal.abort()` is invoked after the first chunk emits
|
||||
- **THEN** the stream closes with an `AbortError`, no further chunks are fetched, and the chunks-plugin loop exits at the next `throwIfAborted` checkpoint
|
||||
|
||||
**Verified by:** `src/file-operations/streams.range.test.ts::"abort mid-range stream cancels in-flight chunk fetches"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `DRIVE_CODES` registry adds `RANGE_INVALID` and `RANGE_OUT_OF_BOUNDS`
|
||||
|
||||
`DRIVE_CODES` SHALL include `"RANGE_INVALID"` and `"RANGE_OUT_OF_BOUNDS"` declared via the existing `defineCodes("DRIVE", [...] as const)` call. `Object.isFrozen(DRIVE_CODES)` MUST remain `true`. Every code value MUST start with `DRIVE.`.
|
||||
|
||||
#### Scenario: registry exposes the new codes
|
||||
|
||||
- **WHEN** the test imports `DRIVE_CODES` from `@suaveplan/drive`
|
||||
- **THEN** `DRIVE_CODES.RANGE_INVALID === "DRIVE.RANGE_INVALID"` AND `DRIVE_CODES.RANGE_OUT_OF_BOUNDS === "DRIVE.RANGE_OUT_OF_BOUNDS"` AND `Object.isFrozen(DRIVE_CODES) === true`
|
||||
|
||||
**Verified by:** `src/error-codes.spec-conformance.test.ts::"DRIVE_CODES exposes RANGE_INVALID and RANGE_OUT_OF_BOUNDS"` AND `src/error-codes.spec-conformance.test.ts::"Object.isFrozen(DRIVE_CODES) is true"`
|
||||
@@ -0,0 +1,79 @@
|
||||
# Tasks — add-drive-range-reads
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/AGENTS.md` §0c, §2b, §6a, §7, §11, §13, §13a, §14
|
||||
- [ ] 1.2 Read `openspec/archive/2026-05-16-add-drive-uri/proposal.md` deferral note and `add-drive-uri` archived delta
|
||||
- [ ] 1.3 Read `openspec/specs/drive/spec.md`, `openspec/specs/drive-uri/spec.md`
|
||||
- [ ] 1.4 Confirm `Drive.createReadStream`, chunks `#reassemble`, mesh `ChunkRequest` shapes in source
|
||||
- [ ] 1.5 Draft `proposal.md` + `design.md` + `tasks.md` + spec deltas
|
||||
- [ ] 1.6 Build the numbered Requirement / Scenario implementation checklist (per §0c step 6)
|
||||
- [ ] 1.7 `openspec validate add-drive-range-reads --strict` exits 0
|
||||
|
||||
## 2. `@suaveplan/drive` — range plumbing
|
||||
|
||||
- [ ] 2.1 `src/types/drive.types.ts` — add `RangeReadOptions` interface; extend `DriveStorage` with optional `capabilities.nativeRange?: boolean`; extend `DriveStorage.createReadStream?` signature with optional `{ offset, length }` (back-compat preserved)
|
||||
- [ ] 2.2 `src/error-codes.ts` — add `RANGE_INVALID`, `RANGE_OUT_OF_BOUNDS` to `DRIVE_CODES`
|
||||
- [ ] 2.3 `src/error-codes.spec-conformance.test.ts` — extend frozen-registry test, every-code-starts-with-`DRIVE.` test
|
||||
- [ ] 2.4 `src/file-operations/streams.ts` — overload `createReadStream(storage, path, signal?)` AND `createReadStream(storage, path, options: RangeReadOptions)`; runtime `instanceof AbortSignal` discriminator; synchronous validation → `DRIVE.RANGE_INVALID`; slicing transform when `!nativeRange`
|
||||
- [ ] 2.5 `src/file-operations/streams.md` — update docs (range API, capability flag, sliced vs. native behaviour)
|
||||
- [ ] 2.6 `src/file-operations/streams.range.test.ts` — boundary table cases: range fully inside one chunk, spans boundary, aligned multi-chunk, tail-to-EOF, zero-length, whole-file, abort mid-stream
|
||||
- [ ] 2.7 `src/file-operations/streams.range.md` — sibling doc
|
||||
- [ ] 2.8 `src/drive/drive.ts` — `createReadStream` overload signatures forwarded to `streams.ts` helpers; middleware context carries `range?: { offset, length? }`
|
||||
- [ ] 2.9 `src/drive/drive.test.ts` — overload-compat (bare signal still works), options form, `DRIVE.RANGE_INVALID` synchronous throw, `DRIVE.RANGE_OUT_OF_BOUNDS` (size-known) synchronous throw
|
||||
|
||||
## 3. `@suaveplan/drive-plugin-chunks` — range-aware reassemble
|
||||
|
||||
- [ ] 3.1 `src/pipeline/range.ts` — pure function `chunkRangeFor({ offset, length, fileSize, chunkSize })` returning `{ firstChunk, lastChunk, firstSkip, lastKeep }` or `null` for zero-length
|
||||
- [ ] 3.2 `src/pipeline/range.md`
|
||||
- [ ] 3.3 `src/pipeline/range.test.ts` — exhaustive table (design § "Worked examples"), fast-check property tests for invariants (`offset + length ≤ fileSize`, slices reassemble to `bytes[offset, end)`)
|
||||
- [ ] 3.4 `src/error-codes.ts` — add `CHUNKS.RANGE_OUT_OF_BOUNDS`
|
||||
- [ ] 3.5 `src/error-codes.spec-conformance.test.ts` — extend
|
||||
- [ ] 3.6 `src/plugin.ts` — refactor `#reassemble` to accept `range?: RangeReadOptions`; sibling `#reassembleAsStream(fileId, range?)`; middleware reads `ctx.range` and forwards; per-iteration `signal.throwIfAborted()`
|
||||
- [ ] 3.7 `src/plugin.md` — update docs (range path, mesh request optimisation, error code)
|
||||
- [ ] 3.8 `src/plugin.range.test.ts` — covers buffered read with range + streaming read with range + `RANGE_OUT_OF_BOUNDS` on oversize range + abort mid-range
|
||||
|
||||
## 4. `@suaveplan/drive-mesh` — range → ChunkRequest pivot
|
||||
|
||||
- [ ] 4.1 `src/pull/range-request.ts` — `buildRangeRequest({ firstChunk, lastChunk, totalChunks })` returns `ChunkRequest.specific` when `span ≤ 64`, else `ChunkRequest.bitset`
|
||||
- [ ] 4.2 `src/pull/range-request.md`
|
||||
- [ ] 4.3 `src/pull/range-request.test.ts` — pivot boundary at exactly 64 chunks; bitset round-trips through `encodeBitset`/`decodeBitset`; specific shape rejects empty span
|
||||
- [ ] 4.4 `src/parallel-puller.ts` — accept range, build request via `buildRangeRequest`, narrow parallelism domain to in-range chunkIds, propagate `AbortSignal`
|
||||
- [ ] 4.5 `src/parallel-puller.test.ts` — extend: only in-range chunks pulled; abort cancels in-flight; small-range uses `specific`, large-range uses `bitset`
|
||||
|
||||
## 5. `@suaveplan/drive-uri` — range pass-through
|
||||
|
||||
- [ ] 5.1 `src/types/plugin-options.types.ts` — extend adapter capabilities (`nativeRange: true`)
|
||||
- [ ] 5.2 `src/adapter/drive-storage-like.ts` — `createReadStream(key, { offset?, length?, signal? })` forwards to `Drive.createReadStream(key, { offset, length, signal })`
|
||||
- [ ] 5.3 `src/adapter/drive-storage-like.md` — update
|
||||
- [ ] 5.4 `src/adapter/drive-storage-like.test.ts` — range forwarded; `nativeRange` capability flag asserted
|
||||
- [ ] 5.5 `src/plugins/drive/drive.streams.ts` — `InstrumentedDriveAdapter.createReadStream` accepts and forwards range; emits `drive.uri.range` span attribute when present
|
||||
- [ ] 5.6 `src/plugins/drive/drive.streams.test.ts` — range round-trip via plugin; abort mid-stream; attribute present iff range present
|
||||
- [ ] 5.7 `src/otel/otel.ts` — `range_chunks_fetched` histogram; `drive.uri.range = "{offset}-{end}"` setter
|
||||
- [ ] 5.8 `src/otel/otel.test.ts` — histogram increments, attribute set, attribute omitted when no range
|
||||
- [ ] 5.9 `tests/integration/drive-range-roundtrip.test.ts` — real `Drive` + storage-file: write 200 KiB file, read bytes [60 000, 70 000) via `resolver.read` flow, assert equality
|
||||
|
||||
## 6. Pre-archive verification (§13)
|
||||
|
||||
- [ ] 6.1 `bunx turbo lint typecheck test --filter=@suaveplan/drive --filter=@suaveplan/drive-plugin-chunks --filter=@suaveplan/drive-mesh --filter=@suaveplan/drive-uri`
|
||||
- [ ] 6.2 `bunx turbo test:coverage` (100 / 100 / 100 / 100 on every touched package)
|
||||
- [ ] 6.3 `bunx turbo build`
|
||||
- [ ] 6.4 Banned-pattern scan; barrel-shape scan; test-runner import scan; missing `.md` scan; README check; bare-version check; subpath export count; security scan
|
||||
- [ ] 6.5 §13 step 10 dependent sweep: `bunx turbo test --filter=...@suaveplan/drive`
|
||||
- [ ] 6.6 Walk every `### Requirement` / `#### Scenario` / `**Verified by:**` — confirm every named test exists and the name matches verbatim
|
||||
|
||||
## 7. Doc + manifest refresh (§13b)
|
||||
|
||||
- [ ] 7.1 Update `packages/drive/drive/README.md`, `packages/drive/drive-plugin-chunks/README.md`, `packages/drive/drive-mesh/README.md`, `packages/drive/drive-uri/README.md`
|
||||
- [ ] 7.2 `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 7.3 `bun scripts/generate-missing.ts`
|
||||
- [ ] 7.4 Update `STATUS.md`, `SUMMARY.md` for the four touched packages
|
||||
- [ ] 7.5 `bunx markdownlint-cli2 --fix`
|
||||
|
||||
## 8. Archival (§14)
|
||||
|
||||
- [ ] 8.1 Fold `specs/drive/spec.md` delta into `openspec/specs/drive/spec.md`
|
||||
- [ ] 8.2 Fold `specs/drive-uri/spec.md` delta into `openspec/specs/drive-uri/spec.md`
|
||||
- [ ] 8.3 Move `openspec/changes/add-drive-range-reads/` → `openspec/archive/<yyyy-mm-dd>-add-drive-range-reads/`
|
||||
@@ -0,0 +1,93 @@
|
||||
# Design — `add-drive-uri-writes`
|
||||
|
||||
Non-normative. The normative contract is `./specs/drive-uri/spec.md`.
|
||||
|
||||
## Per-scheme operation matrix
|
||||
|
||||
| Operation | `drive://` | `drive+mesh://` | `drive+template://` |
|
||||
|---|---|---|---|
|
||||
| `set(key, bytes)` (resolver `write`) | `Drive.write(key, bytes, { signal })` → `void` | `Drive.write(key, bytes, { signal })` → `void`; mesh advertise NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
||||
| `delete(key)` (resolver `remove`) | `Drive.delete(key, signal)` → `boolean` | `Drive.delete(key, signal)` → `boolean`; mesh "no-longer-available" advert NOT triggered | throw `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
||||
| `clear()` | throw `DRIVE_URI.WRITE_UNSUPPORTED` (Drive-wide wipe footgun) | same | same |
|
||||
| `createReadStream` | unchanged | unchanged | unchanged |
|
||||
| `get` / `exists` / `list` / `stat` | unchanged | unchanged | unchanged |
|
||||
|
||||
`clear()` retains `DRIVE_URI.WRITE_UNSUPPORTED` to preserve the existing error contract for that footgun call site. The new `TEMPLATE_READ_ONLY` code is specific to template-backed mutation attempts — operators differentiate "this drive forbids wholesale wipes" (a config-time concern) from "this URI scheme is intrinsically read-only" (an architectural constraint).
|
||||
|
||||
## Error code transition
|
||||
|
||||
| Old behaviour | New behaviour | Code |
|
||||
|---|---|---|
|
||||
| `set` on `drive://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
|
||||
| `set` on `drive+mesh://` → throw `WRITE_UNSUPPORTED` | succeeds | (success) |
|
||||
| `set` on `drive+template://` → throw `WRITE_UNSUPPORTED` | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
||||
| `delete` on `drive+template://` → forwarded to underlying `Drive.delete` (silent footgun: would mutate the hydrated cache, breaking the dehydrated semantics) | throw `TEMPLATE_READ_ONLY` | `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
||||
| `clear()` on any scheme → throw `WRITE_UNSUPPORTED` | unchanged | `DRIVE_URI.WRITE_UNSUPPORTED` |
|
||||
|
||||
`DRIVE_URI.WRITE_UNSUPPORTED` is intentionally retained — consumers who already asserted on it for the `clear()` path continue to work; we narrow its meaning rather than churn the constant.
|
||||
|
||||
## Mesh write — propagation timing and observability
|
||||
|
||||
`drive+mesh://` writes complete when local persist is durable. Specifically:
|
||||
|
||||
1. `Drive.write(path, bytes, { signal })` returns when the installed `chunksPlugin` middleware has resolved (encrypted, chunked, persisted to the configured storage).
|
||||
2. Mesh re-advertise (`mesh.advertise(fileId, bitset, totalChunks)`) is NOT triggered by the URI surface. This matches direct-`Drive` behaviour and avoids surprising consumers who manage `GenerationTracker` / `AdvertiseThrottle` lifecycles themselves.
|
||||
3. The plugin emits a span event `drive_uri.mesh_write_local_only` on every successful `drive+mesh://` write, with attributes `{ scheme: "drive+mesh", path }`. Operators can grep the trace to confirm the contract holds in their environment.
|
||||
4. Consumers wanting gossip propagation invoke `mesh.advertise(fileId, ...)` after the write resolves. Propagation thereafter follows the existing `GossipEngine` timing (best-effort, backoff-throttled, observable via the mesh package's own events).
|
||||
|
||||
This deliberate choice preserves separation of concerns: the URI surface is a routing layer; mesh participation is a Drive-tier policy.
|
||||
|
||||
## Key-rotation impact (none, by inheritance)
|
||||
|
||||
A write may produce data encrypted under a NEW `KeySlotPublic` version. The URI surface does no key derivation, no slot resolution, and no grant signing — those live in `chunksPlugin` and in `Drive.hydrateFrom`'s `key:grant` subscription. The read path validates retroactive grants today; nothing in the write path needs to change for new key versions to be honoured on subsequent reads.
|
||||
|
||||
No new wiring required at the URI tier.
|
||||
|
||||
## AbortSignal threading
|
||||
|
||||
- `resolver.write(uri, body, signal?)` passes `signal` to `InstrumentedDriveAdapter.set(key, value, signal)`.
|
||||
- `InstrumentedDriveAdapter.set` updates its signature to accept `AbortSignal` (currently typed `set(key, value)` per `StorageLike`; we add the third arg as an optional propagated to `Drive.write({ signal })`). Pre-aborted signals short-circuit to `DRIVE_URI.ABORTED` before any work.
|
||||
- `resolver.remove(uri, signal?)` already threads `signal` to `adapter.delete(key)`; we widen the override to accept and forward `signal` to `Drive.delete(path, signal)`.
|
||||
|
||||
`StorageLike` itself does not specify `signal` on `set`/`delete`. `DriveStorageLikeAdapter` adds the parameter as a tolerated extension; the resolver already passes it via the streaming-write surface (`createWriteStream(key, body, signal)`), so the precedent is established.
|
||||
|
||||
## File-layout impact
|
||||
|
||||
New / edited files (under `packages/drive/drive-uri/src/`):
|
||||
|
||||
```
|
||||
adapter/
|
||||
drive-storage-like.ts # widen set/delete to accept signal; forward through
|
||||
drive-storage-like.md # doc update — note new TEMPLATE_READ_ONLY case
|
||||
error-codes/
|
||||
error-codes.ts # add TEMPLATE_READ_ONLY to defineCodes
|
||||
error-codes.md # update; describe narrowed WRITE_UNSUPPORTED role
|
||||
plugins/
|
||||
drive/
|
||||
drive.streams.ts # remove unconditional WRITE_UNSUPPORTED; per-scheme dispatch
|
||||
drive.streams.md # doc update
|
||||
drive-template/
|
||||
drive-template.ts # override set/delete → TEMPLATE_READ_ONLY
|
||||
drive-template.md # doc update
|
||||
otel/
|
||||
otel.ts # add `mesh_write_local_only` span-event constant
|
||||
otel.md # doc update
|
||||
README.md # update Features / Modules sections
|
||||
tests/integration/
|
||||
drive-roundtrip-write.test.ts # NEW — real Drive, write+read cycle
|
||||
drive-mesh-roundtrip-write.test.ts # NEW — mesh-configured Drive, assert no implicit advertise
|
||||
template-write-rejection.test.ts # NEW — TEMPLATE_READ_ONLY round-trip
|
||||
```
|
||||
|
||||
All new code stays inside the existing 300-line / 500-line file budgets; the changed `drive.streams.ts` currently sits at 148 lines and the per-scheme branching adds ~30 lines.
|
||||
|
||||
## Trade-offs considered
|
||||
|
||||
- **Per-scheme `set` dispatch via `if (scheme === "drive+template")` vs. polymorphic adapter subclass.** Chose the inline branch — only one scheme has the constraint and a subclass would duplicate the entire instrumented method body. If a fourth scheme appears, revisit.
|
||||
- **Auto-advertise on mesh writes.** Rejected: violates the direct-`Drive` contract, would surprise consumers who run `GenerationTracker` themselves, and forces every mesh-write call site through an advertise budget. The span event gives operators visibility without enforcing policy.
|
||||
- **Reusing `WRITE_UNSUPPORTED` for template rejections.** Rejected: it conflates a temporary state (the deferred scope this change closes) with a permanent architectural constraint (templates are signed). Distinct codes let consumers branch correctly.
|
||||
- **Removing `WRITE_UNSUPPORTED` entirely.** Rejected: it would churn the public surface (consumers may already assert on it for the `clear()` path) and §2c forbids deleting a registered code without a real reason. Narrowing the role is the right move.
|
||||
|
||||
## Cross-reference
|
||||
|
||||
This change is the second consumer of structural `DriveLike` (after `add-drive-range-reads`), strengthening the case for `hoist-drivelike-to-types`. Independent landing — `drive-uri` imports `DriveLike` from `@suaveplan/drive` today and will move to `@suaveplan/types` once `hoist-drivelike-to-types` archives.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Unblock writes/removes via `drive://`, `drive+mesh://`, `drive+template://`
|
||||
|
||||
**Package**: `@suaveplan/drive-uri`
|
||||
**Status**: PROPOSED
|
||||
**Type**: capability-extension (genesis-original follow-up)
|
||||
**Depends on**: `add-drive-uri` (archived `2026-05-16`) — provides the three plugins + `InstrumentedDriveAdapter`.
|
||||
**Sibling**: `hoist-drivelike-to-types` (independent — landing order is irrelevant; `drive-uri` consumes structural `DriveLike` from whichever source has merged when this lands).
|
||||
**Sibling**: `add-drive-range-reads` (read-side, independent — out of scope here).
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/drive-uri/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The shipped `add-drive-uri` change explicitly deferred writes:
|
||||
|
||||
> *Initial release rejects `write`/`remove` with `DRIVE_URI.WRITE_UNSUPPORTED`. Reason: write semantics for shared/mesh files (chunk re-upload, gossip re-advertise, template re-issue, retroactive key grants) are non-trivial; they belong in a follow-up `add-drive-uri-writes` change once read paths are proven in production.*
|
||||
|
||||
Read paths have shipped, the conformance harness is green, and the four originally-cited blockers all resolve cleanly at the URI tier when analysed per-scheme:
|
||||
|
||||
| Cited blocker | Resolution per scheme |
|
||||
|---|---|
|
||||
| Chunk re-upload | Already handled inside `Drive.write` via the installed `chunksPlugin` middleware. The URI surface forwards `path`/`data`/`signal` unchanged — no chunk codec logic in `drive-uri`. |
|
||||
| Gossip re-advertise | `drive-mesh` exposes `advertise(fileId, bitset, totalChunks)` as an application-controlled call (see `drive-mesh/src/mesh/drive-mesh/drive-mesh.ts:176`). Writes do NOT auto-advertise on direct `Drive` use today, and `drive-uri` MUST preserve that contract — best-effort propagation is observable via the existing mesh events the consumer already subscribes to. |
|
||||
| Template re-issue | Templates are dehydrated, authority-signed descriptors. The resolver cannot re-sign them. `drive+template://` writes are therefore **permanently** rejected with a new specific code `DRIVE_URI.TEMPLATE_READ_ONLY`, not the generic `WRITE_UNSUPPORTED`. |
|
||||
| Retroactive key grants | Inherited from the underlying `Drive` — `key:grant` subscriptions and `KeySlotPublic` rotation live in `Drive.hydrateFrom` and the `chunksPlugin`, not in the URI surface. A new key version added after a write is consumed by future reads through the same path the existing read surface already validates. |
|
||||
|
||||
This change ships the two surfaces that are safe today (`drive://` pass-through and `drive+mesh://` write-then-let-consumer-advertise), permanently locks down the surface that cannot be safely lifted (`drive+template://` writes), and narrows the role of `DRIVE_URI.WRITE_UNSUPPORTED` so it no longer pretends to cover a permanent constraint.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `drive://` writes/removes — `InstrumentedDriveAdapter.set(key, value)` and `.delete(key)` for the `drive` scheme delegate to `Drive.write(path, data, { signal })` and `Drive.delete(path, signal)`. `AbortSignal` is threaded through the resolver `write(uri, body, signal)` and `remove(uri, signal)` surfaces. OTel `drive_uri.writes_total{scheme:"drive", outcome:"success"|"error"}` and `drive_uri.deletes_total{scheme:"drive", outcome}` populated.
|
||||
- ADDED: `drive+mesh://` writes/removes — identical delegation to `Drive.write` / `Drive.delete`; the consumer's installed `chunksPlugin` handles encryption + chunk persistence locally. Mesh re-advertisement is NOT triggered by the URI surface (matching the direct-`Drive` contract); the consumer SHALL call `mesh.advertise(fileId, bitset, totalChunks)` after the write resolves if they want gossip propagation. The plugin documents this contract on the sibling `.md` and surfaces it on a span event `drive_uri.mesh_write_local_only` so operators can audit any environment that expects implicit gossip.
|
||||
- ADDED: `drive+template://` writes/removes — **permanently rejected** with the new code `DRIVE_URI.TEMPLATE_READ_ONLY`. A template-backed drive is a dehydrated, server-signed view; mutating it would require an authority signing key the URI resolver never holds. Removes are rejected for the same reason. `drive_uri.writes_total{scheme:"drive+template", outcome:"error"}` and `drive_uri.deletes_total{scheme:"drive+template", outcome:"error"}` increment on the rejection.
|
||||
- ADDED: New error code `DRIVE_URI.TEMPLATE_READ_ONLY` via `defineCodes` (registry grows from 7 → 8). Reachable from the `drive+template` adapter's `set` and `delete` overrides; both paths covered by tests.
|
||||
- MODIFIED: `DRIVE_URI.WRITE_UNSUPPORTED` retained in the registry but narrowed to a **single call site** — `DriveStorageLikeAdapter.clear()` (the explicit `Drive.delete`-this-instead footgun). It is no longer the rejection code for write/remove via `set`/`delete`. The README, sibling `.md`, and spec all describe this narrower role.
|
||||
- MODIFIED: `InstrumentedDriveAdapter.set` no longer unconditionally throws. It dispatches per the scheme bound at construction: `drive`/`drive+mesh` → delegate; `drive+template` → throw `TEMPLATE_READ_ONLY`. Same for `delete`.
|
||||
- MODIFIED: Conformance — the `runUriPluginConformance` writes suite is re-run against all three plugins. Expected outcomes: `drive://` and `drive+mesh://` complete the full write/read/exists/list/remove cycle; `drive+template://` continues to reject writes (now with `TEMPLATE_READ_ONLY` rather than `WRITE_UNSUPPORTED`).
|
||||
- ADDED: `tests/integration/drive-roundtrip-write.test.ts` — write bytes via `resolver.write("drive://primary/x", bytes)`, read back via `resolver.read`, assert equality. No mocks.
|
||||
- ADDED: `tests/integration/drive-mesh-roundtrip-write.test.ts` — same against a mesh-configured Drive; assert no implicit `advertise` call; explicit `mesh.advertise(...)` after the write produces a verifiable outbound advertisement via the existing transport stub fixture.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Auto-gossip on `drive+mesh://` writes.** Direct `Drive.write` does not auto-advertise today; the URI surface MUST NOT bend that contract. Tracked separately if a future use case proves the policy.
|
||||
- **Template re-issue.** Permanently scoped out — needs an authority signing key the resolver never possesses. The `TEMPLATE_READ_ONLY` rejection is the contract.
|
||||
- **Range reads on `drive+mesh://`.** Independent change `add-drive-range-reads`.
|
||||
- **`DriveLike` hoist to `@suaveplan/types`.** Independent change `hoist-drivelike-to-types`. This change consumes the structural shape from whichever has merged first; it does NOT depend on the hoist landing first.
|
||||
- **New `Drive.write` options** (atomicity, conditional puts, content-type). Out of scope — would require a `Drive`-tier proposal.
|
||||
- **Removing `DRIVE_URI.WRITE_UNSUPPORTED`.** Kept for the `clear()` footgun call site (a stable surface code consumers may already assert on).
|
||||
|
||||
## Source mapping
|
||||
|
||||
- Deferred-text in `openspec/archive/2026-05-16-add-drive-uri/proposal.md` lines 30–38 — the explicit follow-up scope.
|
||||
- `packages/drive/drive-uri/src/plugins/drive/drive.streams.ts:69-84` — current unconditional `set` rejection.
|
||||
- `packages/drive/drive-uri/src/adapter/drive-storage-like.ts:75-86` — `set`/`delete` already pass-through internally; only the instrumented override blocks them.
|
||||
- `packages/drive/drive/src/drive/drive.ts:180-200` — `Drive.write(path, data, options?)` and `Drive.delete(path, signal?)` signatures consumed verbatim.
|
||||
- `packages/drive/drive-mesh/src/mesh/drive-mesh/drive-mesh.ts:176-191` — `mesh.advertise(fileId, bitset, totalChunks)` — confirms gossip is consumer-controlled, not write-triggered.
|
||||
- `packages/drive/drive-template/src/` — no surface here is mutable; the only API touching write is `hydrateFrom`, which is a read-side projection.
|
||||
- `packages/storage/storage-uri/src/resolver/resolver.writes.ts:30-75` — confirms `SuaveplanError` subclasses (including `DriveUriError`) pass through `writeViaPlugin` unchanged, so `TEMPLATE_READ_ONLY` reaches the caller without a `STORAGE_URI.WRITE_UNSUPPORTED` envelope.
|
||||
- `openspec/specs/drive-uri/spec.md` Requirement at line 181 — current global write rejection, replaced by the new per-scheme matrix.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta carries an explicit `**Verified by:**` line referencing planned `it(...)` titles in concrete test files (see delta). The pre-archive gate (§13 of `AGENTS.md`) re-runs the full `runUriPluginConformance` writes suite against all three plugins.
|
||||
|
||||
## Migration
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| `resolver.write("drive://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | succeeds, returns `void` |
|
||||
| `resolver.write("drive+mesh://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | succeeds, returns `void`; gossip remains consumer-controlled |
|
||||
| `resolver.write("drive+template://…", bytes)` throws `DRIVE_URI.WRITE_UNSUPPORTED` | throws `DRIVE_URI.TEMPLATE_READ_ONLY` |
|
||||
| `resolver.remove("drive+template://…", …)` succeeded (via inherited `DriveStorageLikeAdapter.delete`) | throws `DRIVE_URI.TEMPLATE_READ_ONLY` (closes a silent footgun) |
|
||||
| `DRIVE_URI.WRITE_UNSUPPORTED` raised for `set`/`delete` | only raised for `DriveStorageLikeAdapter.clear()` |
|
||||
|
||||
Search consumers: `grep -rln "DRIVE_URI.WRITE_UNSUPPORTED\|WRITE_UNSUPPORTED" packages/ | grep -v "/dist/\|/coverage/\|/drive-uri/"`. Recommended replacement: branch on `err.code === "DRIVE_URI.TEMPLATE_READ_ONLY"` for the immutable template surface; allow the success path elsewhere.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/drive-uri` to enumerate.
|
||||
|
||||
- No external dependents yet (`drive-uri` is consumed only via integration tests at the time of writing) — [x] no action needed (reason: new consumers will adopt the broader surface; existing surface is purely permissive expansion except for `drive+template://` removes, called out in the Migration table).
|
||||
|
||||
### Affected upstreams (none modified)
|
||||
|
||||
| Package | Action | Breaking? |
|
||||
|---|---|---|
|
||||
| `@suaveplan/drive` | None — `Drive.write` / `Drive.delete` consumed as shipped | no |
|
||||
| `@suaveplan/drive-mesh` | None — `advertise` remains consumer-triggered | no |
|
||||
| `@suaveplan/drive-template` | None — no mutable surface exists; `TEMPLATE_READ_ONLY` codifies that fact | no |
|
||||
| `@suaveplan/storage-uri` | None — `writeViaPlugin` already passes through `SuaveplanError` subclasses | no |
|
||||
@@ -0,0 +1,143 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: drive-uri (drive-uri)
|
||||
|
||||
**Package**: `@suaveplan/drive-uri`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement carries a `**Verified by:**` line at authoring time naming planned tests.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `drive://` SHALL support writes and removes via the resolver surface
|
||||
|
||||
The `drive` plugin's instrumented adapter SHALL delegate `set(key, value, signal?)` to `Drive.write(path, data, { signal })` and `delete(key, signal?)` to `Drive.delete(path, signal)`. Successful writes SHALL increment `drive_uri.writes_total{scheme:"drive", outcome:"success"}`; successful removes SHALL increment `drive_uri.deletes_total{scheme:"drive", outcome:"success"}`. The `AbortSignal` passed to `resolver.write(uri, body, signal)` / `resolver.remove(uri, signal)` SHALL be threaded through to the underlying `Drive` call. A pre-aborted signal SHALL short-circuit to `DRIVE_URI.ABORTED` without invoking `Drive.write` / `Drive.delete`.
|
||||
|
||||
#### Scenario: write round-trips via Drive.write
|
||||
|
||||
- **WHEN** `resolver.write("drive://primary/clip.mov", bytes)` is called against a `drive` plugin bound to a real local-storage `Drive`
|
||||
- **THEN** the call resolves with `void`, the underlying `Drive` reports the path persisted, and `resolver.read("drive://primary/clip.mov")` returns `bytes`
|
||||
|
||||
#### Scenario: remove delegates to Drive.delete
|
||||
|
||||
- **WHEN** `resolver.remove("drive://primary/clip.mov")` is called after a successful write
|
||||
- **THEN** the call resolves with `true` and a subsequent `resolver.read(...)` rejects with `STORAGE_URI.NOT_FOUND`
|
||||
|
||||
#### Scenario: AbortSignal threads through writes
|
||||
|
||||
- **WHEN** a `resolver.write(...)` is invoked with an `AbortSignal` that aborts mid-call
|
||||
- **THEN** the call rejects with `DRIVE_URI.ABORTED` and any underlying `Drive.write` invocation observes the abort
|
||||
|
||||
**Verified by:** `src/plugins/drive/drive.test.ts::"writes succeed and round-trip via Drive.write"` AND `src/plugins/drive/drive.test.ts::"removes delegate to Drive.delete and return its boolean"` AND `src/plugins/drive/drive.test.ts::"writes propagate AbortSignal to Drive.write"` AND `tests/integration/drive-roundtrip-write.test.ts::"resolver.write then resolver.read round-trips on a real Drive"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `drive+mesh://` SHALL support writes/removes locally without triggering mesh re-advertisement
|
||||
|
||||
The `drive+mesh` plugin's instrumented adapter SHALL delegate `set`/`delete` to the underlying `Drive` exactly as the `drive` scheme does. It SHALL NOT call `mesh.advertise(...)` or any equivalent gossip API; gossip propagation remains the consumer's responsibility. On every successful `drive+mesh://` write, the plugin SHALL emit a span event named `drive_uri.mesh_write_local_only` carrying `{ scheme: "drive+mesh", path }` so operators can audit that the contract holds.
|
||||
|
||||
#### Scenario: write persists locally without auto-advertise
|
||||
|
||||
- **WHEN** `resolver.write("drive+mesh://swarmA/clip.mov", bytes)` is called against a mesh-configured `Drive` with a spy transport
|
||||
- **THEN** the call resolves with `void`, the bytes are persisted via `Drive.write`, and the spy transport records zero `advertise(...)` calls
|
||||
|
||||
#### Scenario: explicit consumer-side advertise still works
|
||||
|
||||
- **WHEN** the consumer invokes `mesh.advertise(fileId, bitset, totalChunks)` after the write resolves
|
||||
- **THEN** the spy transport records one outbound advertisement
|
||||
|
||||
#### Scenario: span event marks local-only write
|
||||
|
||||
- **WHEN** any successful `drive+mesh://` write completes
|
||||
- **THEN** the active span carries an event `drive_uri.mesh_write_local_only` with attributes `{ scheme: "drive+mesh", path }`
|
||||
|
||||
**Verified by:** `src/plugins/drive-mesh/drive-mesh.test.ts::"writes succeed without triggering mesh.advertise"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"writes emit drive_uri.mesh_write_local_only span event"` AND `tests/integration/drive-mesh-roundtrip-write.test.ts::"explicit mesh.advertise after write produces an outbound advertisement"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `drive+template://` writes and removes SHALL be permanently rejected with `DRIVE_URI.TEMPLATE_READ_ONLY`
|
||||
|
||||
The `drive+template` plugin's instrumented adapter SHALL throw a `DriveUriError` with `code === "DRIVE_URI.TEMPLATE_READ_ONLY"` on every `set` and `delete` call, before any delegation to `Drive`. The error SHALL carry `scheme: "drive+template"`, the offending `path`, and the `templateId` that was bound. The rejection SHALL increment `drive_uri.writes_total{scheme:"drive+template", outcome:"error"}` for writes and `drive_uri.deletes_total{scheme:"drive+template", outcome:"error"}` for removes. The error message SHALL state that template-backed drives require authority re-signing not held by the resolver.
|
||||
|
||||
#### Scenario: write against drive+template:// rejects with TEMPLATE_READ_ONLY
|
||||
|
||||
- **WHEN** `resolver.write("drive+template://tplA/clip.mov", bytes)` is called
|
||||
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.TEMPLATE_READ_ONLY"`, `scheme === "drive+template"`, `path === "clip.mov"`, and `templateId === "tplA"`
|
||||
|
||||
#### Scenario: remove against drive+template:// rejects with TEMPLATE_READ_ONLY
|
||||
|
||||
- **WHEN** `resolver.remove("drive+template://tplA/clip.mov")` is called
|
||||
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.TEMPLATE_READ_ONLY"`
|
||||
|
||||
#### Scenario: rejection increments error counters
|
||||
|
||||
- **WHEN** any `drive+template://` write rejection fires
|
||||
- **THEN** `drive_uri.writes_total{scheme:"drive+template", outcome:"error", code:"DRIVE_URI.TEMPLATE_READ_ONLY"}` increments by 1
|
||||
|
||||
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` AND `src/plugins/drive-template/drive-template.test.ts::"removes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` AND `src/otel/otel.test.ts::"increments writes_total{outcome:error,code:TEMPLATE_READ_ONLY} on template writes"` AND `tests/integration/template-write-rejection.test.ts::"resolver.write and resolver.remove both reject on template-backed drive"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `DRIVE_URI_CODES` SHALL include `TEMPLATE_READ_ONLY`
|
||||
|
||||
The `DRIVE_URI_CODES` registry produced by `defineCodes("DRIVE_URI", […])` SHALL include `TEMPLATE_READ_ONLY` as a distinct, fully-qualified `DRIVE_URI.TEMPLATE_READ_ONLY` code. The `DriveUriErrorCode` union SHALL include it. The code SHALL be reachable from real call sites (the `drive+template` adapter's `set` and `delete` overrides).
|
||||
|
||||
#### Scenario: registry exposes TEMPLATE_READ_ONLY
|
||||
|
||||
- **WHEN** `DRIVE_URI_CODES.TEMPLATE_READ_ONLY` is read
|
||||
- **THEN** the value is the string `"DRIVE_URI.TEMPLATE_READ_ONLY"`
|
||||
|
||||
#### Scenario: registry remains size-stable elsewhere
|
||||
|
||||
- **WHEN** the registry's key count is asserted
|
||||
- **THEN** it equals 8, comprising the original 7 plus `TEMPLATE_READ_ONLY`
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"includes TEMPLATE_READ_ONLY and totals 8 codes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `runUriPluginConformance` writes suite SHALL pass for `drive`/`drive+mesh` and reject for `drive+template`
|
||||
|
||||
The conformance harness re-exported from `@suaveplan/storage-uri` SHALL be re-run against each of the three plugins. The `drive` and `drive+mesh` plugins SHALL pass the full write/read/exists/list/remove cycle. The `drive+template` plugin SHALL continue to fail the write/remove portions of the suite, but with `DRIVE_URI.TEMPLATE_READ_ONLY` as the rejection code rather than `WRITE_UNSUPPORTED`.
|
||||
|
||||
#### Scenario: conformance writes suite passes for drive
|
||||
|
||||
- **WHEN** the harness is configured with a real-`Drive`-backed `drive` plugin and asked to exercise the writes suite
|
||||
- **THEN** every write/read round-trip assertion holds and no test is skipped
|
||||
|
||||
#### Scenario: conformance writes suite passes for drive+mesh
|
||||
|
||||
- **WHEN** the harness is configured with a mesh-configured `Drive`-backed `drive+mesh` plugin
|
||||
- **THEN** every write/read round-trip holds; no `advertise` is observed on the spy transport during the harness
|
||||
|
||||
#### Scenario: conformance writes suite rejects drive+template with the new code
|
||||
|
||||
- **WHEN** the harness is configured with a `drive+template` plugin and exercises any write/remove
|
||||
- **THEN** every such call rejects with `DRIVE_URI.TEMPLATE_READ_ONLY`
|
||||
|
||||
**Verified by:** `src/conformance/conformance.test.ts::"writes suite passes for drive plugin"` AND `src/conformance/conformance.test.ts::"writes suite passes for drive+mesh plugin"` AND `src/conformance/conformance.test.ts::"writes suite rejects drive+template with TEMPLATE_READ_ONLY"`
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: `DRIVE_URI.WRITE_UNSUPPORTED` SHALL only be raised by `DriveStorageLikeAdapter.clear()`
|
||||
|
||||
Previously the three plugins rejected every `set` (write) call with `DRIVE_URI.WRITE_UNSUPPORTED`. The code SHALL now be raised by exactly one call site: `DriveStorageLikeAdapter.clear()`, which intentionally refuses to wipe an entire Drive through the URI surface and points the caller at `Drive.delete(path)` (or `resolver.remove(uri)`). The code SHALL remain in `DRIVE_URI_CODES` so consumers that already assert on it for the `clear()` path continue to function unchanged. No other call site in `drive-uri` SHALL raise `WRITE_UNSUPPORTED`.
|
||||
|
||||
#### Scenario: clear() still rejects with WRITE_UNSUPPORTED
|
||||
|
||||
- **WHEN** any consumer invokes `adapter.clear()` on a `DriveStorageLikeAdapter` (directly or via an unusual resolver extension)
|
||||
- **THEN** the call rejects with `DriveUriError` whose `code === "DRIVE_URI.WRITE_UNSUPPORTED"`
|
||||
|
||||
#### Scenario: set on drive:// no longer raises WRITE_UNSUPPORTED
|
||||
|
||||
- **WHEN** `resolver.write("drive://primary/x", bytes)` is called against a real `Drive`
|
||||
- **THEN** the call resolves with `void` and no `DRIVE_URI.WRITE_UNSUPPORTED` is thrown anywhere in the call chain
|
||||
|
||||
#### Scenario: set on drive+template:// raises TEMPLATE_READ_ONLY not WRITE_UNSUPPORTED
|
||||
|
||||
- **WHEN** `resolver.write("drive+template://tplA/x", bytes)` is called
|
||||
- **THEN** the rejection's `code` is `DRIVE_URI.TEMPLATE_READ_ONLY`, never `DRIVE_URI.WRITE_UNSUPPORTED`
|
||||
|
||||
**Verified by:** `src/adapter/drive-storage-like.test.ts::"clear() rejects with DRIVE_URI.WRITE_UNSUPPORTED"` AND `src/plugins/drive/drive.test.ts::"writes succeed and round-trip via Drive.write"` AND `src/plugins/drive-template/drive-template.test.ts::"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"`
|
||||
@@ -0,0 +1,60 @@
|
||||
# Tasks — `add-drive-uri-writes`
|
||||
|
||||
## 1. Registry
|
||||
|
||||
- [ ] 1.1 Add `TEMPLATE_READ_ONLY` to `DRIVE_URI_CODES` in `src/error-codes/error-codes.ts` (8 codes total).
|
||||
- [ ] 1.2 Update `src/error-codes/error-codes.md` — describe `TEMPLATE_READ_ONLY` and the narrowed role of `WRITE_UNSUPPORTED`.
|
||||
- [ ] 1.3 Extend `src/error-codes/error-codes.test.ts` to cover the new code and assert `WRITE_UNSUPPORTED` is still present.
|
||||
|
||||
## 2. Adapter — `DriveStorageLikeAdapter`
|
||||
|
||||
- [ ] 2.1 Widen `set(key, value, signal?)` and `delete(key, signal?)` to accept and forward `AbortSignal` to `Drive.write({ signal })` / `Drive.delete(path, signal)`.
|
||||
- [ ] 2.2 Update `src/adapter/drive-storage-like.md` for the new signature and note the `TEMPLATE_READ_ONLY` rejection happens in the instrumented override, not here.
|
||||
- [ ] 2.3 Extend `src/adapter/drive-storage-like.test.ts` — `set` propagates `signal`, `delete` propagates `signal`, pre-aborted signal short-circuits.
|
||||
|
||||
## 3. Instrumented adapter — `InstrumentedDriveAdapter`
|
||||
|
||||
- [ ] 3.1 Replace the unconditional `set` rejection with per-scheme dispatch: `drive`/`drive+mesh` → delegate to `super.set(key, value, signal)`; `drive+template` → throw `DRIVE_URI.TEMPLATE_READ_ONLY`.
|
||||
- [ ] 3.2 Override `delete` symmetrically: `drive`/`drive+mesh` → delegate; `drive+template` → throw `TEMPLATE_READ_ONLY`.
|
||||
- [ ] 3.3 On every successful `drive+mesh://` write, emit span event `drive_uri.mesh_write_local_only` with `{ scheme, path }`.
|
||||
- [ ] 3.4 Pre-aborted `signal` on `set`/`delete` → throw `DRIVE_URI.ABORTED` before any work.
|
||||
- [ ] 3.5 OTel counters: increment `drive_uri.writes_total{scheme, outcome}` and `drive_uri.deletes_total{scheme, outcome}` for every success and every rejection.
|
||||
- [ ] 3.6 Update `src/plugins/drive/drive.streams.md` and `src/plugins/drive-template/drive-template.md`.
|
||||
|
||||
## 4. Test suite
|
||||
|
||||
- [ ] 4.1 `src/plugins/drive/drive.test.ts` — replace `"rejects writes with DRIVE_URI.WRITE_UNSUPPORTED"` with `"writes succeed and round-trip via Drive.write"` and `"writes propagate AbortSignal to Drive.write"`.
|
||||
- [ ] 4.2 `src/plugins/drive/drive.test.ts` — add `"removes delegate to Drive.delete and return its boolean"` and `"removes propagate AbortSignal"`.
|
||||
- [ ] 4.3 `src/plugins/drive-mesh/drive-mesh.test.ts` — add `"writes succeed without triggering mesh.advertise"` and `"writes emit drive_uri.mesh_write_local_only span event"`.
|
||||
- [ ] 4.4 `src/plugins/drive-template/drive-template.test.ts` — add `"writes reject with DRIVE_URI.TEMPLATE_READ_ONLY"` and `"removes reject with DRIVE_URI.TEMPLATE_READ_ONLY"`.
|
||||
- [ ] 4.5 `src/otel/otel.test.ts` — `"increments writes_total{outcome:success} on successful drive:// writes"`, `"increments writes_total{outcome:error,code:TEMPLATE_READ_ONLY} on template writes"`, `"increments deletes_total per scheme outcome"`.
|
||||
- [ ] 4.6 `src/conformance/conformance.test.ts` — re-run `runUriPluginConformance` writes suite against all three plugins. Expected: `drive`/`drive+mesh` pass; `drive+template` continues to fail writes with the new code.
|
||||
- [ ] 4.7 `tests/integration/drive-roundtrip-write.test.ts` — real local-storage Drive, `resolver.write` then `resolver.read`, assert equality.
|
||||
- [ ] 4.8 `tests/integration/drive-mesh-roundtrip-write.test.ts` — mesh-configured Drive with a spy transport; assert no implicit `advertise` call; explicit `mesh.advertise(...)` after the write produces a verifiable outbound advertisement.
|
||||
- [ ] 4.9 `tests/integration/template-write-rejection.test.ts` — wired template-backed Drive, `resolver.write("drive+template://...")` and `resolver.remove(...)` both reject with `TEMPLATE_READ_ONLY`.
|
||||
|
||||
## 5. Migration audit
|
||||
|
||||
- [ ] 5.1 `grep -rln "DRIVE_URI.WRITE_UNSUPPORTED" packages/ | grep -v "/dist/\|/coverage/\|/drive-uri/"` — every hit is reviewed; non-matching consumers re-routed onto `TEMPLATE_READ_ONLY` where appropriate.
|
||||
- [ ] 5.2 Update `README.md` Features section to reflect writes/removes via `drive://` and `drive+mesh://`.
|
||||
|
||||
## 6. Pre-publish gate (§13)
|
||||
|
||||
- [ ] 6.1 Spec reconciliation — walk every `### Requirement` and `#### Scenario` in `specs/drive-uri/spec.md`; confirm the named test exists verbatim.
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive-uri` — green.
|
||||
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/drive-uri` — 100/100/100/100, including the `TEMPLATE_READ_ONLY` rejection branches.
|
||||
- [ ] 6.4 `bunx turbo build --filter=@suaveplan/drive-uri` — green.
|
||||
- [ ] 6.5 Banned-pattern, barrel, test-runner-import, missing-`.md`, bare-version, and secrets scans — all clean.
|
||||
- [ ] 6.6 Dependent sweep — `bunx turbo test --filter=...@suaveplan/drive-uri` green.
|
||||
|
||||
## 7. Root-doc refresh (§13b)
|
||||
|
||||
- [ ] 7.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`.
|
||||
- [ ] 7.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml`.
|
||||
- [ ] 7.3 Update `STATUS.md` and `SUMMARY.md` paragraphs for `drive-uri` to reflect writes-supported on the two mutable schemes.
|
||||
- [ ] 7.4 `bunx markdownlint-cli2 --fix` over the touched docs.
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [ ] 8.1 Fold `specs/drive-uri/spec.md` (deltas) into `openspec/specs/drive-uri/spec.md`.
|
||||
- [ ] 8.2 Move `openspec/changes/add-drive-uri-writes/` → `openspec/archive/<yyyy-mm-dd>-add-drive-uri-writes/`.
|
||||
@@ -0,0 +1,253 @@
|
||||
# Design: add-installer-upgrade
|
||||
|
||||
This document captures the non-normative design for the deferred upgrade flow.
|
||||
Normative requirements live in `specs/installer/spec.md`.
|
||||
|
||||
## Flow diagram
|
||||
|
||||
```text
|
||||
suaveplan-install upgrade <plugin> [--from X] [--to Y] [--dry-run] [--force]
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Host.upgrade(plugin, options) │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├─► loadState(fs, cwd)
|
||||
│ state-file: .suaveplan/installer-state.json
|
||||
│ missing entry → INSTALLER.UPGRADE_NOT_INSTALLED
|
||||
│
|
||||
├─► resolveTargetVersion(plugin, cwd)
|
||||
│ reads node_modules/@<scope>/<pkg>/package.json
|
||||
│ missing → INSTALLER.UPGRADE_VERSION_NOT_FOUND
|
||||
│
|
||||
├─► if installed === target && !force
|
||||
│ → INSTALLER.UPGRADE_SAME_VERSION (idempotent no-op)
|
||||
│
|
||||
├─► preflight
|
||||
│ ├─ detectLockfile() → UPGRADE_LOCKFILE_MISSING
|
||||
│ └─ detectDirtyTree() → UPGRADE_DIRTY_TREE (unless --allow-dirty)
|
||||
│
|
||||
├─► hashTree(targetDir) → currentHashes (rollback snapshot key)
|
||||
│
|
||||
├─► plan(answers, ctx) at fromVersion → fromOps
|
||||
│ plan(answers, ctx) at toVersion → toOps
|
||||
│
|
||||
├─► computeDiff(fromOps, toOps)
|
||||
│ { added, removed, changed, unchanged }
|
||||
│
|
||||
├─► materialiseDiff(diff) → FileOp[]
|
||||
│ additions → write
|
||||
│ removals → delete
|
||||
│ changes → write (overwrite) | patch-json (merge)
|
||||
│
|
||||
├─► Host.applyOps(ops) ◄── EXISTING TRANSACTIONAL PATH
|
||||
│ staged-tmp + atomic rename (INSTALLER-04)
|
||||
│
|
||||
├─► runMigrations(selected, ctx)
|
||||
│ throws → rollback transaction → UPGRADE_MIGRATION_FAILED
|
||||
│
|
||||
├─► writeState({ pluginId, version: to, installedAt, fileHashes })
|
||||
│ atomic write → fsync → rename
|
||||
│
|
||||
└─► OTel: span "installer.upgrade" closes with attributes
|
||||
plugin.id, plugin.version.from, plugin.version.to, dry_run,
|
||||
files.added, files.removed, files.changed
|
||||
```
|
||||
|
||||
## Atomicity model
|
||||
|
||||
The upgrade is **one** transaction, not two. The diff-apply step and the
|
||||
migration-run step share the same staged-tmp directory created by
|
||||
`Host.applyOps`. The window is:
|
||||
|
||||
1. **Stage** — every `write`/`patch-*` op writes its content to
|
||||
`<targetDir>/.tmp-<txn>/<path>`. Deletions are recorded as
|
||||
"move-aside to `<targetDir>/.tmp-<txn>/.deleted/<path>`" — files are
|
||||
**moved**, not removed, so rollback can restore them. No live files have
|
||||
been mutated yet.
|
||||
2. **Migrate** — `MigrationScript`s run against the staged tree (their
|
||||
`MigrationContext.fs` is rooted at the tmp directory). They can mutate
|
||||
staged files or emit additional `FileOp`s, which are appended to the
|
||||
plan and re-staged into the same tmp directory.
|
||||
3. **Commit** — once every stage step has succeeded, the host issues atomic
|
||||
`rename(tmp, live)` for every staged path **in dependency order** (parent
|
||||
dirs before children for `write`, children before parents for `delete`).
|
||||
4. **Persist state** — only after every rename commits does the host write
|
||||
the new `installer-state.json` (itself via the same write-fsync-rename
|
||||
dance scoped to one file).
|
||||
|
||||
Any failure in steps 1–3 walks the journal in reverse: staged tmp files are
|
||||
unlinked; moved-aside files are renamed back; the state-file is untouched.
|
||||
The user's tree is bit-identical to the pre-upgrade state.
|
||||
|
||||
A rollback failure (e.g. fs returns EIO on the restore rename) surfaces as
|
||||
`InstallerError(INSTALLER.ROLLBACK_FAILED, { cause: <original apply error> })`
|
||||
exactly as `INSTALLER-04` already prescribes for the `add` flow.
|
||||
|
||||
## Snapshot / rollback strategy
|
||||
|
||||
The "snapshot" is not a copy of the tree — that would be linear in target
|
||||
size and unacceptable for a skills bundle. Instead, the snapshot is:
|
||||
|
||||
1. **The pre-upgrade `hashTree`** — recorded before staging so the upgrade
|
||||
has a content-addressed manifest of what existed.
|
||||
2. **The move-aside journal** — every file the diff intends to delete or
|
||||
overwrite is moved to `<tmp>/.deleted/<path>` (rename is O(1) on the
|
||||
same filesystem). Disk usage during upgrade equals
|
||||
`size(diff.changed ∪ diff.removed)` rather than `size(entire tree)`.
|
||||
3. **The new staged content** — every `write` lands in `<tmp>/<path>` and
|
||||
is `rename`d in only on commit.
|
||||
|
||||
Rollback walks the journal in reverse: rename moved-aside files back to
|
||||
their live paths, unlink staged files, rmdir the tmp directory.
|
||||
|
||||
## Migration script contract
|
||||
|
||||
```ts
|
||||
// In a plugin: src/upgrade.ts
|
||||
import type { MigrationTable, MigrationScript } from "@suaveplan/installer/migrations";
|
||||
|
||||
const v1_to_v2: MigrationScript = async (ctx) => {
|
||||
// ctx.fs is rooted at the staged tmp directory.
|
||||
// ctx.signal honours --abort.
|
||||
// ctx.logger is the host's structured logger.
|
||||
// ctx.from = "1.0.0", ctx.to = "2.0.0"
|
||||
const old = await ctx.fs.readFile("skills/legacy.json");
|
||||
await ctx.fs.writeFile("skills/legacy.yaml", convert(old));
|
||||
await ctx.fs.delete("skills/legacy.json");
|
||||
};
|
||||
|
||||
export const upgradeTable: MigrationTable = {
|
||||
"1.0.0->2.0.0": v1_to_v2,
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keys are `"<from>-><to>"` semver strings. The selector finds the shortest
|
||||
path of contiguous edges from the installed version to the target.
|
||||
- Scripts MUST be pure with respect to outside-the-tmp state. Network
|
||||
access is permitted (e.g. fetching upstream renames) but discouraged.
|
||||
- Scripts MAY throw. A throw rolls the transaction back and surfaces
|
||||
`INSTALLER.UPGRADE_MIGRATION_FAILED` with `{ cause }`.
|
||||
- Scripts MAY return additional `FileOp[]` that the host appends to the
|
||||
plan and re-stages before commit.
|
||||
|
||||
## CLI behaviour
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--dry-run` | Computes diff + migration list, prints the plan, exits before staging. |
|
||||
| `--force` | Collapses conflict policies to `overwrite` AND bypasses the dirty-tree guard. Equivalent to `--allow-dirty` + per-file overwrite. |
|
||||
| `--allow-dirty` | Bypasses dirty-tree guard but does NOT change conflict policies. Used when the operator knows their edits are reconcilable. |
|
||||
| `--no-interactive` | Any prompt op fails with `INSTALLER.APPLY_CONFLICT` (existing semantics). |
|
||||
| `--from <version>` | Overrides the version detected from the state file. Useful when the state file is missing or stale. |
|
||||
| `--to <version>` | Overrides the version resolved from `node_modules`. Useful for pin-rollforward simulations. |
|
||||
| `--answers <file>` | Same as `add` — JSON file supplying answers for any prompts the plan emits. |
|
||||
|
||||
## Idempotency
|
||||
|
||||
Two notions, both held:
|
||||
|
||||
1. **Same-version upgrade is a no-op.** `installed == target` exits with
|
||||
`INSTALLER.UPGRADE_SAME_VERSION` without staging anything.
|
||||
2. **Successful upgrade is replayable.** If the user runs the same upgrade
|
||||
again, the state file already reflects `target`, so step 1 fires. There
|
||||
is no way to "double-apply" a migration script.
|
||||
|
||||
## State file shape
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "installer-state/v1",
|
||||
"plugins": {
|
||||
"agent-skills": {
|
||||
"version": "0.7.0",
|
||||
"installedAt": "2026-05-17T12:34:56Z",
|
||||
"targetDir": ".suaveplan/skills",
|
||||
"fileHashes": {
|
||||
"skills/crypto/SKILL.md": "blake3:...",
|
||||
"skills/events/SKILL.md": "blake3:..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The schema version (`$schema`) is bumped any time the shape changes; the
|
||||
loader rejects unknown versions with `INSTALLER.UPGRADE_STATE_INVALID`.
|
||||
|
||||
## Coverage strategy
|
||||
|
||||
Two fixture plugins live under `src/testing/fixtures/`:
|
||||
|
||||
- `upgrade-fixture-plugin@1.0.0` — emits two files
|
||||
(`a.txt`, `b.txt`) and one `patch-json` op against `data.json`.
|
||||
- `upgrade-fixture-plugin@2.0.0` — drops `b.txt`, renames `a.txt` →
|
||||
`a-renamed.txt`, adds `c.txt`, changes the `patch-json` merge, and ships
|
||||
one migration script `1.0.0->2.0.0` that rewrites `data.json` schema.
|
||||
|
||||
Together they exercise every branch in `computeDiff`, every conflict policy
|
||||
in the diff materialiser, both the migration-success and migration-throw
|
||||
paths, and both the same-version-noop and version-not-found paths. 100%
|
||||
istanbul falls out naturally.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/tooling/installer/src/
|
||||
state/
|
||||
state.types.ts
|
||||
state.schemas.ts
|
||||
state-file.ts
|
||||
state-file.md
|
||||
state-file.spec-conformance.test.ts
|
||||
hash-tree.ts
|
||||
hash-tree.md
|
||||
host/
|
||||
upgrade/
|
||||
upgrade.ts
|
||||
upgrade.md
|
||||
upgrade.spec-conformance.test.ts
|
||||
preflight.ts
|
||||
preflight.md
|
||||
preflight.spec-conformance.test.ts
|
||||
dirty-tree.spec-conformance.test.ts
|
||||
diff.ts
|
||||
diff.md
|
||||
apply-diff.spec-conformance.test.ts
|
||||
migrations.ts
|
||||
migrations.types.ts
|
||||
migrations.md
|
||||
migrations.spec-conformance.test.ts
|
||||
index.ts
|
||||
testing/
|
||||
upgrade-plugin.ts
|
||||
upgrade-plugin.md
|
||||
fixtures/
|
||||
upgrade-fixture-plugin.ts
|
||||
upgrade-fixture-plugin.md
|
||||
```
|
||||
|
||||
## Decisions and trade-offs
|
||||
|
||||
- **Move-aside vs full snapshot.** Move-aside wastes one rename per
|
||||
affected file but is O(1) per file. A full snapshot would be safer
|
||||
against concurrent writes but the installer already requires the user
|
||||
to not be editing files concurrently — `INSTALLER-04` makes the same
|
||||
trade-off for `add`. Consistency with `add` wins.
|
||||
- **BLAKE3 over SHA-256.** `@suaveplan/crypto-hash` ships both. BLAKE3 is
|
||||
~3× faster for the small-file case (skill files average <4 KB) and
|
||||
parallelisable. No security requirement (this is a change-detection
|
||||
hash, not a tamper-proof signature).
|
||||
- **Migration scripts run AFTER diff stage, not before.** Running them
|
||||
before would mean diffing against a tree the migration script has
|
||||
already mutated, which makes the "did the user modify this file?"
|
||||
question ambiguous. The order is: diff first (against the unmodified
|
||||
staged tree), then migrate.
|
||||
- **No network resolution.** Resolving "latest" from npm would couple the
|
||||
installer to a registry shape, force network access in CI, and conflict
|
||||
with the project's air-gapped use cases. The target version is
|
||||
whatever resolves from `node_modules`; the operator bumps the dep
|
||||
with their package manager first.
|
||||
@@ -0,0 +1,145 @@
|
||||
# OpenSpec Change: add-installer-upgrade
|
||||
|
||||
- **Change ID**: add-installer-upgrade
|
||||
- **Package**: `@suaveplan/installer`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-17
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/installer/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" deferred
|
||||
the upgrade flow:
|
||||
|
||||
> Plugin upgrade flow (`upgrade <plugin>` to bring an existing install up to
|
||||
> current bundle version) is a follow-up — initial scope is install + reinstall
|
||||
> (idempotent re-run with conflict policy).
|
||||
|
||||
The installer therefore ships `add <plugin>` and `list` but has no answer for a
|
||||
user who installed `@suaveplan/install-agent-skills@0.5.0` six months ago and
|
||||
wants the changes that landed in `0.7.0`. Today their only options are
|
||||
`add agent-skills --force` (which overwrites local edits without diffing) or a
|
||||
manual `rm -rf` (which destroys their in-flight skill edits). Both are unsafe.
|
||||
|
||||
The `add` path is also unable to run plugin-authored migration scripts, so any
|
||||
breaking layout change in a plugin bundle (e.g. `skills/<id>/SKILL.md` →
|
||||
`skills/<id>/<id>.md`) silently leaves stale files behind that the user has to
|
||||
hand-prune.
|
||||
|
||||
## What Changes
|
||||
|
||||
Add a third top-level installer command, `suaveplan-install upgrade <plugin>`,
|
||||
that resolves the **installed** plugin version from the on-disk state file,
|
||||
resolves the **target** version from `node_modules`, computes the file-level
|
||||
diff between the two bundles (added / removed / changed), and applies it
|
||||
transactionally with the same staged-tmp + rollback strategy `apply` already
|
||||
uses for `add`. Pre-flight gates protect against in-flight edits (dirty target
|
||||
tree) and missing lockfiles. Plugins MAY ship an optional `upgrade.ts`
|
||||
migration script per from-version → to-version pair; the host invokes them in
|
||||
order inside the same transaction.
|
||||
|
||||
### Surface added
|
||||
|
||||
- `suaveplan-install upgrade <plugin>` CLI verb with flags
|
||||
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
|
||||
`--from <version>` (override detected), `--to <version>` (target; defaults
|
||||
to whatever resolves from `node_modules`), `--allow-dirty` (skip dirty-tree
|
||||
guard — destructive, opt-in only).
|
||||
- `Host.upgrade(plugin, options)` public entry point alongside `Host.run`.
|
||||
- A persisted state file (`.suaveplan/installer-state.json`) capturing
|
||||
`{ pluginId, version, installedAt, fileHashes }` per installed plugin so
|
||||
diff and dirty-tree detection are deterministic on subsequent upgrades.
|
||||
- A `MigrationContext` and `MigrationScript` contract under
|
||||
`@suaveplan/installer/migrations` for plugin-authored scripts.
|
||||
- 7 new `INSTALLER.*` codes: `UPGRADE_NOT_INSTALLED`,
|
||||
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
|
||||
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`,
|
||||
`UPGRADE_LOCKFILE_MISSING`.
|
||||
|
||||
### Behaviour guarantees
|
||||
|
||||
- Re-running `upgrade <plugin>` when installed == target is a no-op exiting
|
||||
with `INSTALLER.UPGRADE_SAME_VERSION` (treated as success under `--dry-run`).
|
||||
- Failure at any point (diff compute, migration script throw, atomic rename
|
||||
error) rolls the target tree back to the pre-upgrade snapshot. Partial
|
||||
state is never persisted.
|
||||
- The state file is updated atomically (`write → fsync → rename`) only after
|
||||
the transaction commits.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Source / archive reference | What it tells us |
|
||||
|---|---|
|
||||
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Out of scope" bullet 2 | The deferred upgrade flow is the scope of this change. |
|
||||
| `openspec/archive/2026-04-28-installer-port/proposal.md` §"Architecture → Lifecycle" | Reuse the existing `detect → prompts → plan → apply → postInstall` pipeline; upgrade adds a diff-and-migrate stage between `plan` and `apply`. |
|
||||
| `openspec/specs/installer/spec.md` `INSTALLER-04` (transactional apply) | Upgrade MUST reuse the same staged-tmp + rollback machinery — no parallel transaction model. |
|
||||
| `openspec/specs/installer/spec.md` `INSTALLER-05` (conflict policy) | Per-file conflict policy already exists; upgrade extends it with a "user-modified-since-install" case detected via the state-file hashes. |
|
||||
| `openspec/specs/installer/spec.md` `INSTALLER-09` (frozen registry) | New codes MUST extend `defineCodes("INSTALLER", [...])` — no parallel registry. |
|
||||
| `packages/tooling/installer/src/bin/suaveplan-install.ts` | Existing CLI shape: `cli().command("add <plugin>")…`. The new verb attaches identically: `.command("upgrade <plugin>")`. |
|
||||
| `packages/tooling/installer/src/host/host.ts` | `Host` class is the orchestration seam; `Host.upgrade` slots in alongside `Host.run`. |
|
||||
| `packages/tooling/installer/src/host/apply/apply.ts` | Staged-tmp transaction implementation is reused as-is; upgrade composes it. |
|
||||
| `packages/tooling/installer/src/manifest/discover.ts` | Version resolution from `node_modules` already exists for `add`; upgrade reuses `findPlugin(cwd, pluginId)`. |
|
||||
| `packages/tooling/installer/src/error-codes.ts` | New codes APPEND to the existing tuple; the registry stays frozen. |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/installer` to enumerate. Current
|
||||
dependents:
|
||||
|
||||
- `@suaveplan/install-agent-skills` — [x] tests run / [x] docs reviewed /
|
||||
[ ] no action needed. The skills plugin is the canonical upgrade target;
|
||||
its existing `runPlugin` harness gains an `upgradePlugin` sibling so its
|
||||
conformance tests cover both flows. The plugin itself receives a tiny
|
||||
optional `upgrade.ts` exporting an empty migration table (no breaking
|
||||
layout changes yet); this proves the migration contract loads even when
|
||||
the table is empty.
|
||||
|
||||
No other dependents currently consume `@suaveplan/installer`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Downgrade.** `upgrade` only moves forward. A separate
|
||||
`add-installer-downgrade` change can extend the diff engine in reverse;
|
||||
the migration contract is intentionally directional (`from`/`to` versions)
|
||||
so downgrade is a different scope.
|
||||
- **Cross-plugin coordination.** Upgrading `install-agent-skills` does not
|
||||
trigger an upgrade of a hypothetical `install-web-server`. Each plugin is
|
||||
upgraded independently; `peerInstallers` field warnings are a future
|
||||
packaging change.
|
||||
- **Background / scheduled upgrade daemon.** Upgrade is operator-initiated
|
||||
via the CLI; there is no auto-update behaviour.
|
||||
- **Network resolution.** The "target version" is whatever resolves from
|
||||
`node_modules`; the installer never talks to npm directly. Bumping the
|
||||
installed package version remains the user's package-manager step.
|
||||
|
||||
## Verification
|
||||
|
||||
Every requirement in `specs/installer/spec.md` carries a `**Verified by:**`
|
||||
line pointing to a conformance test in `src/host/upgrade/` or
|
||||
`src/state/`. The full set:
|
||||
|
||||
- `INSTALLER-UPGRADE-01` Lifecycle order → `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
|
||||
- `INSTALLER-UPGRADE-02` Idempotent same-version no-op → `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
|
||||
- `INSTALLER-UPGRADE-03` Dirty-tree guard → `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
|
||||
- `INSTALLER-UPGRADE-04` Lockfile pre-flight → `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
|
||||
- `INSTALLER-UPGRADE-05` Transactional diff application → `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
|
||||
- `INSTALLER-UPGRADE-06` Migration script contract → `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
|
||||
- `INSTALLER-UPGRADE-07` Migration failure rollback → `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
|
||||
- `INSTALLER-UPGRADE-08` State-file integrity → `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
|
||||
- `INSTALLER-UPGRADE-09` Error-code registry namespacing → `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
|
||||
- `INSTALLER-UPGRADE-10` Version-not-found surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
|
||||
- `INSTALLER-UPGRADE-11` Not-installed surface → `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
|
||||
- `INSTALLER-UPGRADE-12` CLI flag wiring → `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
|
||||
- `bunx turbo test:coverage --filter=@suaveplan/installer` 100% istanbul on
|
||||
every new module under `src/host/upgrade/`, `src/state/`, and the
|
||||
upgrade-specific additions to `src/bin/suaveplan-install.ts`.
|
||||
- Sibling `.md` for every new implementation `.ts` per `openspec/AGENTS.md` §4.
|
||||
- `openspec validate add-installer-upgrade --strict` green.
|
||||
- Spec delta folded into `openspec/specs/installer/spec.md` on archive.
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: installer (upgrade flow)
|
||||
|
||||
**Package**: `@suaveplan/installer`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
> Test files are planned paths inside `packages/tooling/installer/`.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Upgrade lifecycle order
|
||||
|
||||
`Host.upgrade(plugin, options)` SHALL execute upgrade stages in the strict
|
||||
order `loadState → resolveTarget → preflight → hashTree → planFrom → planTo →
|
||||
diff → materialise → applyOps → runMigrations → commitState`. Optional stages
|
||||
(`detect`, `prompts`, `postInstall` from the install lifecycle) SHALL still be
|
||||
honoured before `loadState` and after `commitState` respectively, preserving
|
||||
plugin composability. Each stage SHALL be wrapped in an OpenTelemetry span
|
||||
named `installer.upgrade.<stage>` whose parent span `installer.upgrade`
|
||||
carries attributes `plugin.id`, `plugin.version.from`, `plugin.version.to`,
|
||||
and `dry_run`.
|
||||
|
||||
#### Scenario: pipeline executes in the documented order
|
||||
|
||||
- **WHEN** `Host.upgrade(fixturePlugin, { from: "1.0.0", to: "2.0.0" })` runs
|
||||
against an installed `1.0.0` fixture and a `2.0.0` bundle in `node_modules`
|
||||
- **THEN** the OTel exporter records spans in the order `loadState`,
|
||||
`resolveTarget`, `preflight`, `hashTree`, `planFrom`, `planTo`, `diff`,
|
||||
`materialise`, `applyOps`, `runMigrations`, `commitState`, all parented to
|
||||
one `installer.upgrade` span carrying `plugin.id`, `plugin.version.from`,
|
||||
`plugin.version.to`, and `dry_run`.
|
||||
|
||||
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"runs detect → resolve → diff → migrate → apply → postInstall in order"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Same-version upgrade is idempotent
|
||||
|
||||
`Host.upgrade` SHALL short-circuit when the installed version recorded in
|
||||
the state file equals the target version resolved from `node_modules`, and
|
||||
surface `InstallerError(INSTALLER.UPGRADE_SAME_VERSION)`. Callers MAY treat
|
||||
this code as a success indicator. Re-running the same upgrade after a
|
||||
successful commit SHALL therefore always exit via this short-circuit. The
|
||||
short-circuit SHALL NOT fire when `--force` was supplied.
|
||||
|
||||
#### Scenario: re-running upgrade after success exits with UPGRADE_SAME_VERSION
|
||||
|
||||
- **WHEN** `Host.upgrade` is invoked twice in succession against the same
|
||||
fixture plugin with no `--force` flag
|
||||
- **THEN** the first invocation MUST commit and write the state file, and
|
||||
the second invocation MUST throw `InstallerError` with code
|
||||
`INSTALLER.UPGRADE_SAME_VERSION` and MUST NOT stage any file
|
||||
|
||||
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"exits with UPGRADE_SAME_VERSION when installed equals target"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dirty-tree pre-flight guard
|
||||
|
||||
Before staging any file op, `Host.upgrade` SHALL hash every path recorded in
|
||||
the state file's `fileHashes` and compare to the recorded value. Any
|
||||
mismatched hash SHALL surface as
|
||||
`InstallerError(INSTALLER.UPGRADE_DIRTY_TREE, { context: { paths: string[] } })`
|
||||
unless `--allow-dirty` or `--force` was supplied. The error context SHALL
|
||||
enumerate every divergent path so the operator can reconcile manually.
|
||||
|
||||
#### Scenario: divergent on-disk hash blocks the upgrade
|
||||
|
||||
- **GIVEN** a state file recording `skills/crypto/SKILL.md` at hash `H1`
|
||||
- **WHEN** the file on disk hashes to `H2` and `Host.upgrade` runs without
|
||||
`--allow-dirty` or `--force`
|
||||
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_DIRTY_TREE` is
|
||||
thrown whose `context.paths` includes `skills/crypto/SKILL.md`
|
||||
|
||||
**Verified by:** `src/host/upgrade/dirty-tree.spec-conformance.test.ts::"throws UPGRADE_DIRTY_TREE when state-file hash diverges from disk"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lockfile presence is required
|
||||
|
||||
`Host.upgrade` SHALL refuse to run unless at least one of
|
||||
`bun.lockb`, `bun.lock`, `package-lock.json`, `pnpm-lock.yaml`, or
|
||||
`yarn.lock` is present in `cwd`. Missing lockfile SHALL surface as
|
||||
`InstallerError(INSTALLER.UPGRADE_LOCKFILE_MISSING)`. This gate exists to
|
||||
prevent upgrades against a tree whose dependency graph is unpinned, where
|
||||
the resolved target version could shift between runs.
|
||||
|
||||
#### Scenario: missing lockfile blocks the upgrade
|
||||
|
||||
- **WHEN** `Host.upgrade` runs in a `cwd` with no lockfile of any supported
|
||||
format
|
||||
- **THEN** an `InstallerError` with code `INSTALLER.UPGRADE_LOCKFILE_MISSING`
|
||||
is thrown before any file is hashed
|
||||
|
||||
**Verified by:** `src/host/upgrade/preflight.spec-conformance.test.ts::"throws UPGRADE_LOCKFILE_MISSING when no bun.lockb / package-lock.json / pnpm-lock.yaml exists"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Transactional diff application with rollback
|
||||
|
||||
`Host.upgrade` SHALL materialise the computed diff (`added`, `removed`,
|
||||
`changed`) into the existing `FileOp` union and feed it through
|
||||
`Host.applyOps`, reusing the staged-tmp + atomic-rename transaction
|
||||
described in the canonical `INSTALLER-04` requirement. Removed files SHALL
|
||||
be moved aside into `<tmp>/.deleted/` rather than unlinked, so rollback can
|
||||
restore them. Any failure during staging or commit SHALL trigger a
|
||||
reverse-order rollback that unlinks staged files and renames the
|
||||
moved-aside files back into place. Rollback failures SHALL surface as
|
||||
`InstallerError(INSTALLER.ROLLBACK_FAILED, { cause })`.
|
||||
|
||||
#### Scenario: rename failure rolls the tree back
|
||||
|
||||
- **GIVEN** a fixture plugin whose final `rename` of `a-renamed.txt` is
|
||||
injected to throw EIO
|
||||
- **WHEN** `Host.upgrade` runs against an installed `1.0.0` tree containing
|
||||
`a.txt`
|
||||
- **THEN** `a.txt` exists at its original content after the throw and
|
||||
`a-renamed.txt` does not exist, and the throw surfaces as `InstallerError`
|
||||
with code `INSTALLER.APPLY_FAILED` (or `INSTALLER.ROLLBACK_FAILED` if the
|
||||
restore also fails) carrying the EIO as `cause`
|
||||
|
||||
**Verified by:** `src/host/upgrade/apply-diff.spec-conformance.test.ts::"rolls back snapshot on staged-rename failure"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Migration scripts run in semver order
|
||||
|
||||
`Host.upgrade` SHALL, when a plugin ships an `upgrade.ts` exporting a
|
||||
`MigrationTable`, load the table, select the shortest contiguous chain
|
||||
of edges from `from` to `to`, and execute each `MigrationScript` in order
|
||||
inside the same transaction as the diff apply. Each script SHALL receive a
|
||||
`MigrationContext` carrying the same `fs` (rooted at the staged tmp dir),
|
||||
the same `AbortSignal`, the host `logger`, and the `from`/`to` semver
|
||||
strings for the current edge.
|
||||
|
||||
#### Scenario: chained migrations execute from-to-target in order
|
||||
|
||||
- **GIVEN** a plugin with edges `1.0.0->1.1.0` and `1.1.0->2.0.0`
|
||||
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
|
||||
- **THEN** both scripts MUST run, the `1.0.0->1.1.0` script MUST run
|
||||
first, both MUST receive an `fs` rooted at the same staged tmp
|
||||
directory, and both MUST observe the same `AbortSignal` instance
|
||||
|
||||
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"runs upgrade.ts scripts in semver order between from and to"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Migration failure rolls back the transaction
|
||||
|
||||
A `MigrationScript` throw SHALL roll back every staged op (including any
|
||||
ops emitted by earlier migration scripts in the same chain) and SHALL
|
||||
surface as `InstallerError(INSTALLER.UPGRADE_MIGRATION_FAILED, { cause })`.
|
||||
The state file SHALL NOT be written when a migration throws. Re-running
|
||||
the upgrade after a migration failure SHALL therefore see the original
|
||||
installed version in the state file and start the chain from the
|
||||
beginning.
|
||||
|
||||
#### Scenario: throwing migration leaves tree at pre-upgrade state
|
||||
|
||||
- **GIVEN** a plugin whose `1.0.0->2.0.0` migration throws on invocation
|
||||
- **WHEN** `Host.upgrade` runs from `1.0.0` to `2.0.0`
|
||||
- **THEN** the throw surfaces as `InstallerError` with code
|
||||
`INSTALLER.UPGRADE_MIGRATION_FAILED`, the live tree is bit-identical to
|
||||
its pre-upgrade content, and the state file still records version
|
||||
`1.0.0`
|
||||
|
||||
**Verified by:** `src/host/upgrade/migrations.spec-conformance.test.ts::"rolls back transaction and surfaces UPGRADE_MIGRATION_FAILED on script throw"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: State file is written atomically on commit only
|
||||
|
||||
`Host.upgrade` SHALL persist the new `installer-state.json` entry only
|
||||
after every diff op and migration script has committed successfully. The
|
||||
write SHALL use the write-fsync-rename pattern: serialise to
|
||||
`<cwd>/.suaveplan/installer-state.json.tmp-<txn>`, `fsync` the descriptor,
|
||||
then `rename` over the live path. Reads SHALL Zod-validate the loaded
|
||||
state; validation failures SHALL surface as
|
||||
`InstallerError(INSTALLER.UPGRADE_STATE_INVALID, { cause })`.
|
||||
|
||||
#### Scenario: state file appears only after successful commit
|
||||
|
||||
- **WHEN** `Host.upgrade` aborts mid-apply (any failure)
|
||||
- **THEN** the file at `.suaveplan/installer-state.json` is unchanged from
|
||||
its pre-upgrade content (either still recording the old version or
|
||||
still absent), and no `.tmp-*` sibling remains on disk
|
||||
|
||||
**Verified by:** `src/state/state-file.spec-conformance.test.ts::"atomically writes installer-state.json only on successful commit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Upgrade error codes are namespaced and frozen
|
||||
|
||||
The `INSTALLER_CODES` registry SHALL include `UPGRADE_NOT_INSTALLED`,
|
||||
`UPGRADE_VERSION_NOT_FOUND`, `UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`,
|
||||
`UPGRADE_STATE_INVALID`, `UPGRADE_MIGRATION_FAILED`, and
|
||||
`UPGRADE_LOCKFILE_MISSING` in addition to the existing nine codes. The
|
||||
registry SHALL remain frozen (`Object.isFrozen(INSTALLER_CODES) === true`).
|
||||
Every new value SHALL start with `INSTALLER.`. `InstallerError` SHALL
|
||||
remain the only error class thrown by the upgrade path.
|
||||
|
||||
#### Scenario: registry contains the upgrade codes and stays frozen
|
||||
|
||||
- **WHEN** the registry is inspected at module-load time
|
||||
- **THEN** `INSTALLER_CODES.UPGRADE_NOT_INSTALLED` equals
|
||||
`"INSTALLER.UPGRADE_NOT_INSTALLED"`, every other upgrade code is
|
||||
present with the same `INSTALLER.` prefix, and
|
||||
`Object.isFrozen(INSTALLER_CODES)` returns `true`
|
||||
|
||||
**Verified by:** `src/error-codes.spec-conformance.test.ts::"upgrade codes are frozen and namespaced INSTALLER.*"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Missing target version surfaces a typed error
|
||||
|
||||
`Host.upgrade` SHALL surface
|
||||
`InstallerError(INSTALLER.UPGRADE_VERSION_NOT_FOUND, { context: { pluginId, requested } })`
|
||||
when the requested target version (whether supplied via `--to` or resolved
|
||||
from `node_modules`) does not exist.
|
||||
The error SHALL be thrown before any pre-flight check runs so callers can
|
||||
distinguish "plugin not installed at target" from "plugin tree dirty".
|
||||
|
||||
#### Scenario: requesting an unresolvable target version fails early
|
||||
|
||||
- **GIVEN** an installed plugin at `1.0.0` and `node_modules` containing
|
||||
only `1.0.0`
|
||||
- **WHEN** `Host.upgrade(plugin, { to: "9.9.9" })` runs
|
||||
- **THEN** an `InstallerError` with code
|
||||
`INSTALLER.UPGRADE_VERSION_NOT_FOUND` MUST be thrown and the
|
||||
`context.requested` field MUST equal `"9.9.9"`
|
||||
|
||||
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_VERSION_NOT_FOUND when plugin not in node_modules"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Upgrading an uninstalled plugin surfaces a typed error
|
||||
|
||||
`Host.upgrade` SHALL surface
|
||||
`InstallerError(INSTALLER.UPGRADE_NOT_INSTALLED, { context: { pluginId } })`
|
||||
when no entry for the requested `pluginId` exists in the state file.
|
||||
The error SHALL be thrown before any version resolution runs so the
|
||||
operator gets the most specific guidance ("run `add` first") without
|
||||
network or filesystem cost.
|
||||
|
||||
#### Scenario: upgrade against empty state file fails fast
|
||||
|
||||
- **GIVEN** an empty `.suaveplan/installer-state.json` (no `plugins` entry
|
||||
for the requested id)
|
||||
- **WHEN** `Host.upgrade(plugin)` runs
|
||||
- **THEN** an `InstallerError` with code
|
||||
`INSTALLER.UPGRADE_NOT_INSTALLED` MUST be thrown before
|
||||
`resolveTargetVersion` is called
|
||||
|
||||
**Verified by:** `src/host/upgrade/upgrade.spec-conformance.test.ts::"throws UPGRADE_NOT_INSTALLED when no state-file entry exists"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CLI verb forwards every upgrade flag
|
||||
|
||||
`suaveplan-install upgrade <plugin>` SHALL parse and forward the flags
|
||||
`--dry-run`, `--force`, `--no-interactive`, `--answers <file>`,
|
||||
`--from <version>`, `--to <version>`, and `--allow-dirty` to
|
||||
`Host.upgrade`. The verb SHALL share the same `InstallerContext`
|
||||
construction path as `add`, so the underlying `fs`, `logger`, `cli`,
|
||||
`templates`, and `signal` are identical. `--dry-run` SHALL print the
|
||||
computed diff (counts of added/removed/changed plus the migration chain)
|
||||
and exit with code 0 before any staging occurs.
|
||||
|
||||
#### Scenario: each upgrade flag reaches Host.upgrade
|
||||
|
||||
- **WHEN** `main(["upgrade", "agent-skills", "--dry-run", "--force", "--allow-dirty", "--from", "1.0.0", "--to", "2.0.0", "--answers", "a.json"])`
|
||||
runs with a recording `Host.upgrade` stub
|
||||
- **THEN** the stub records one call whose options object contains
|
||||
`{ dryRun: true, force: true, allowDirty: true, from: "1.0.0", to: "2.0.0", answers: <parsed JSON> }`
|
||||
|
||||
**Verified by:** `src/bin/suaveplan-install.spec-conformance.test.ts::"upgrade verb forwards --dry-run, --force, --allow-dirty, --from, --to to Host.upgrade"`
|
||||
@@ -0,0 +1,134 @@
|
||||
# Tasks: add-installer-upgrade
|
||||
|
||||
## 1. Error codes
|
||||
|
||||
- [ ] 1.1 Append `UPGRADE_NOT_INSTALLED`, `UPGRADE_VERSION_NOT_FOUND`,
|
||||
`UPGRADE_SAME_VERSION`, `UPGRADE_DIRTY_TREE`, `UPGRADE_STATE_INVALID`,
|
||||
`UPGRADE_MIGRATION_FAILED`, `UPGRADE_LOCKFILE_MISSING` to the
|
||||
`defineCodes("INSTALLER", [...])` tuple in
|
||||
`packages/tooling/installer/src/error-codes.ts`.
|
||||
- [ ] 1.2 Extend `src/error-codes.spec-conformance.test.ts` with an
|
||||
`INSTALLER-UPGRADE-09` block asserting the codes are present, frozen,
|
||||
and prefixed `INSTALLER.`.
|
||||
|
||||
## 2. State file (`src/state/`)
|
||||
|
||||
- [ ] 2.1 `src/state/state.types.ts` — `InstallerState`, `InstalledPlugin`,
|
||||
`FileHash` types.
|
||||
- [ ] 2.2 `src/state/state.schemas.ts` — Zod schemas for boundary validation.
|
||||
- [ ] 2.3 `src/state/state-file.ts` — `loadState(fs, cwd)`,
|
||||
`writeState(fs, cwd, state)` (atomic write → fsync → rename),
|
||||
`updatePlugin(state, entry)`. Failure → `INSTALLER.UPGRADE_STATE_INVALID`.
|
||||
- [ ] 2.4 `src/state/hash-tree.ts` — `hashTree(fs, dir)` → `Record<path, hex>`
|
||||
using BLAKE3 via `@suaveplan/crypto-hash`.
|
||||
- [ ] 2.5 `src/state/state-file.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-08`.
|
||||
- [ ] 2.6 `.md` siblings for every implementation file.
|
||||
|
||||
## 3. Pre-flight checks (`src/host/upgrade/preflight.ts`)
|
||||
|
||||
- [ ] 3.1 `detectLockfile(fs, cwd)` returning `"bun" | "npm" | "pnpm" | "yarn"`
|
||||
or throwing `INSTALLER.UPGRADE_LOCKFILE_MISSING`.
|
||||
- [ ] 3.2 `detectDirtyTree(state, currentHashes)` returning the list of
|
||||
user-modified paths. Throws `INSTALLER.UPGRADE_DIRTY_TREE` unless
|
||||
`--allow-dirty` or `--force` was supplied.
|
||||
- [ ] 3.3 `src/host/upgrade/preflight.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-04`.
|
||||
- [ ] 3.4 `src/host/upgrade/dirty-tree.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-03`.
|
||||
|
||||
## 4. Diff engine (`src/host/upgrade/diff.ts`)
|
||||
|
||||
- [ ] 4.1 `computeDiff(fromOps, toOps)` returning
|
||||
`{ added: FileOp[], removed: FileOp[], changed: FileOp[], unchanged: FileOp[] }`.
|
||||
- [ ] 4.2 `materialiseDiff(diff, options)` translating the diff into the
|
||||
existing `FileOp` union: removals → `{ kind: "delete" }`, additions →
|
||||
`{ kind: "write" }`, changes → `{ kind: "write", conflict: "overwrite" }`
|
||||
(or `merge` for `patch-json` ops).
|
||||
- [ ] 4.3 Sibling `diff.md` covering algorithm and tie-breaking rules.
|
||||
|
||||
## 5. Migration runner (`src/host/upgrade/migrations.ts`)
|
||||
|
||||
- [ ] 5.1 `MigrationScript`, `MigrationContext`, `MigrationTable` types in
|
||||
`src/host/upgrade/migrations.types.ts`.
|
||||
- [ ] 5.2 `loadMigrationTable(plugin)` dynamic-imports the plugin's
|
||||
`./upgrade.js` if declared in the manifest; absent table → no-op.
|
||||
- [ ] 5.3 `selectMigrations(table, from, to)` returns the ordered semver path.
|
||||
- [ ] 5.4 `runMigrations(selected, ctx)` runs each script inside an
|
||||
`AbortSignal`-propagating sandbox. Throw → `INSTALLER.UPGRADE_MIGRATION_FAILED`.
|
||||
- [ ] 5.5 `src/host/upgrade/migrations.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-06`, `INSTALLER-UPGRADE-07`.
|
||||
|
||||
## 6. Upgrade orchestration (`src/host/upgrade/upgrade.ts`)
|
||||
|
||||
- [ ] 6.1 `Host.upgrade(plugin, options)` method:
|
||||
1. Load state-file entry; throw `UPGRADE_NOT_INSTALLED` if absent.
|
||||
2. Resolve target version; throw `UPGRADE_VERSION_NOT_FOUND` if
|
||||
`node_modules` resolution fails.
|
||||
3. Short-circuit with `UPGRADE_SAME_VERSION` when installed === target
|
||||
and not `--force`.
|
||||
4. Run preflight (lockfile + dirty-tree).
|
||||
5. Hash current tree; compute snapshot for rollback.
|
||||
6. Run `plugin.plan` for both versions (or load cached bundle manifests).
|
||||
7. `computeDiff` → `materialiseDiff` → `Host.applyOps` (existing
|
||||
transactional path).
|
||||
8. Run migrations between the same staged-tmp transaction window.
|
||||
9. On success, `writeState` atomically.
|
||||
- [ ] 6.2 OTel span `installer.upgrade` wrapping the whole flow with
|
||||
`plugin.id`, `plugin.version.from`, `plugin.version.to`, `dry_run`.
|
||||
- [ ] 6.3 `src/host/upgrade/upgrade.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-01`, `INSTALLER-UPGRADE-02`, `INSTALLER-UPGRADE-10`,
|
||||
`INSTALLER-UPGRADE-11`.
|
||||
- [ ] 6.4 `src/host/upgrade/apply-diff.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-05`.
|
||||
|
||||
## 7. CLI wiring (`src/bin/suaveplan-install.ts`)
|
||||
|
||||
- [ ] 7.1 Add `app.command("upgrade <plugin>")` with flags `--dry-run`,
|
||||
`--force`, `--no-interactive`, `--answers <file>`, `--from <version>`,
|
||||
`--to <version>`, `--allow-dirty`.
|
||||
- [ ] 7.2 `runUpgrade(pluginId, flags)` mirrors `runAdd` but calls
|
||||
`host.upgrade(...)`.
|
||||
- [ ] 7.3 `src/bin/suaveplan-install.spec-conformance.test.ts` —
|
||||
`INSTALLER-UPGRADE-12`.
|
||||
|
||||
## 8. Testing helpers (`src/testing/`)
|
||||
|
||||
- [ ] 8.1 `src/testing/upgrade-plugin.ts` — `upgradePlugin(plugin, { fromState, toBundle, answers? })`
|
||||
mirroring the existing `runPlugin` harness for upgrade tests.
|
||||
- [ ] 8.2 `src/testing/fixtures/upgrade-fixture-plugin.ts` — two-version
|
||||
fixture plugin (`1.0.0` + `2.0.0`) with one migration script, used by
|
||||
every upgrade conformance test for end-to-end coverage at 100%.
|
||||
|
||||
## 9. Docs
|
||||
|
||||
- [ ] 9.1 Sibling `.md` for every new implementation `.ts` per
|
||||
`openspec/AGENTS.md` §4 (Purpose, Features, Basic, Advanced, API, Notes).
|
||||
- [ ] 9.2 Update root `packages/tooling/installer/README.md` Quickstart to
|
||||
include the `upgrade` verb and link the new module docs.
|
||||
|
||||
## 10. Quality gates
|
||||
|
||||
- [ ] 10.1 `bunx turbo lint typecheck test --filter=@suaveplan/installer` green.
|
||||
- [ ] 10.2 `bunx turbo test:coverage --filter=@suaveplan/installer` 100%
|
||||
istanbul on every new module.
|
||||
- [ ] 10.3 No banned patterns (`openspec/AGENTS.md` §2) in new files.
|
||||
- [ ] 10.4 No `biome-ignore` in new files (`openspec/AGENTS.md` §2a).
|
||||
- [ ] 10.5 File-size check: every `src/host/upgrade/*.ts` ≤ 300 lines,
|
||||
every `*.test.ts` ≤ 500 lines.
|
||||
- [ ] 10.6 Sibling `.md` audit returns zero gaps for new files.
|
||||
|
||||
## 11. Dependent sweep
|
||||
|
||||
- [ ] 11.1 `bunx turbo test --filter=...@suaveplan/installer` green
|
||||
(validates `@suaveplan/install-agent-skills` against the new surface).
|
||||
- [ ] 11.2 Add empty `upgrade.ts` migration table export to
|
||||
`@suaveplan/install-agent-skills` and wire it through its manifest so
|
||||
the migration-loader path is exercised even with zero migrations.
|
||||
|
||||
## 12. Archive
|
||||
|
||||
- [ ] 12.1 Fold `specs/installer/spec.md` delta into
|
||||
`openspec/specs/installer/spec.md`.
|
||||
- [ ] 12.2 Move `openspec/changes/add-installer-upgrade/` to
|
||||
`openspec/archive/<yyyy-mm-dd>-add-installer-upgrade/`.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Design — `usePasskeyAutofill` (`@suaveplan/react-auth`)
|
||||
|
||||
Non-normative. Architecture, lifecycle, and rationale. The normative contract lives in `specs/react-auth/spec.md`.
|
||||
|
||||
## Package shape
|
||||
|
||||
`@suaveplan/react-auth` is created by this change. It is a lightweight browser-tier package at `packages/auth/react-auth/`. Tier is `browser` (React 19 peer dep, DOM types fine). It holds exactly this hook and the optional `<PasskeyAutofillField>` wrapper today; future auth-related React hooks land in follow-up changes under the same package.
|
||||
|
||||
The hook lives at `src/use-passkey-autofill/use-passkey-autofill.ts`; the component at `src/passkey-autofill-field/passkey-autofill-field.tsx`; errors at `src/errors/errors.ts`. Each has a sibling `.md`, a `.test.ts` (or `.test.tsx`), and stories per CLAUDE.md.
|
||||
|
||||
## Conditional UI background
|
||||
|
||||
WebAuthn Level 3 §5.1.4 defines `mediation: "conditional"`. Browsers that support it surface available platform credentials in the autocomplete sheet of any `<input>` with `autocomplete="username webauthn"`. The feature is gated on `PublicKeyCredential.isConditionalMediationAvailable()` — a static method that does not exist on Safari < 17, Firefox < 119, or any SSR environment. Calling `navigator.credentials.get({ publicKey, mediation: "conditional" })` without that check throws `NotSupportedError` on older browsers, which `@suaveplan/webauthn` maps to `WEBAUTHN.NOT_SUPPORTED`.
|
||||
|
||||
The hook MUST therefore feature-detect before dispatching. Because feature detection is async and DOM-only, it can only run inside a `useEffect`.
|
||||
|
||||
## Hook signature
|
||||
|
||||
```ts
|
||||
interface UsePasskeyAutofillOptions {
|
||||
rpId: string;
|
||||
challenge: Uint8Array;
|
||||
onCredential: (result: AuthenticateResult) => void;
|
||||
onError?: (error: SuaveplanError) => void;
|
||||
signal?: AbortSignal;
|
||||
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||
userVerification?: UserVerificationRequirement;
|
||||
timeout?: number;
|
||||
expectedOrigin?: string;
|
||||
expectedRpIdHash?: Uint8Array;
|
||||
}
|
||||
|
||||
interface UsePasskeyAutofillReturn {
|
||||
ready: boolean;
|
||||
error: SuaveplanError | null;
|
||||
abort: () => void;
|
||||
}
|
||||
|
||||
function usePasskeyAutofill(opts: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn;
|
||||
```
|
||||
|
||||
`ready` flips to `true` after `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` Promise is in-flight. `error` carries the most recent translated error (cleared when a new attempt starts). `abort` is a stable `useCallback` that fires the internal `AbortController`.
|
||||
|
||||
## Lifecycle — AbortController ownership
|
||||
|
||||
The hook owns exactly one `AbortController` per "session" (one mount, one option-identity). The controller is composed with the caller's `options.signal` via a small `composeSignals(...signals)` helper (any aborts → composed aborts).
|
||||
|
||||
| Event | Behavior |
|
||||
|---|---|
|
||||
| Mount (client) | Run feature detect → dispatch `authenticate({ mediation: "conditional", signal: composed })`. |
|
||||
| Mount (server) | No-op. The `useEffect` never runs. |
|
||||
| Unmount | Call `controller.abort()`. The underlying `navigator.credentials.get` rejects with `AbortError`, mapped to `WEBAUTHN.ABORTED` → `REACT_AUTH.ABORTED`. |
|
||||
| Caller `signal` aborts | Composed controller aborts → same as unmount path. |
|
||||
| Caller calls returned `abort()` | Same as above. State remains mounted; consumer may re-trigger by changing option identity. |
|
||||
| `<input>` blur (via `<PasskeyAutofillField>`) | Consumer-controlled. The component calls `abort()` on `onBlur` IF no credential has been delivered yet. The hook alone does not subscribe to blur (it does not touch the DOM). |
|
||||
| Option identity changes (e.g. fresh `challenge`) | Effect re-runs: old controller aborts, new controller created, new request dispatched. |
|
||||
| `onCredential` fires | Controller is left intact (the request is already settled); `error` cleared. |
|
||||
| `onError` fires | Controller aborted; `error` stored; consumer may remount or change challenge to retry. |
|
||||
|
||||
The hook MUST NOT touch the DOM directly — it works alongside an `<input autocomplete="username webauthn">` rendered by the consumer. The browser surfaces the autofill UI based on the autocomplete token; the hook just keeps the conditional `authenticate` request pending so the browser has something to deliver into.
|
||||
|
||||
## SSR safety
|
||||
|
||||
- `typeof window === "undefined"` short-circuits before any `navigator` access.
|
||||
- `isConditionalMediationAvailable()` is called inside `useEffect` (which never runs server-side under React 19 SSR).
|
||||
- The hook returns `{ ready: false, error: null, abort: noop }` on the server render, then re-renders on hydration.
|
||||
|
||||
## Error translation
|
||||
|
||||
```text
|
||||
WEBAUTHN.NOT_SUPPORTED → REACT_AUTH.AUTOFILL_UNAVAILABLE
|
||||
WEBAUTHN.ABORTED → REACT_AUTH.ABORTED
|
||||
all other WEBAUTHN.* → REACT_AUTH.AUTOFILL_FAILED (cause-chained)
|
||||
Zod schema rejection at entry → REACT_AUTH.INVALID_OPTIONS (thrown synchronously)
|
||||
SSR / non-DOM environment → REACT_AUTH.SSR_NO_OP (surfaced via onError only)
|
||||
```
|
||||
|
||||
Every translation preserves the original `SuaveplanError` via `cause`, so consumers can introspect `error.cause?.code` for the underlying `WEBAUTHN.*` code.
|
||||
|
||||
## Testing approach
|
||||
|
||||
- Real `navigator.credentials.get` stub at the jsdom layer — install a fake `PublicKeyCredential` with `isConditionalMediationAvailable` and a `navigator.credentials.get` that resolves a fixture `AuthenticateResult` or rejects with a chosen `DOMException`. Tests live in `src/use-passkey-autofill/use-passkey-autofill.test.tsx` using `@suaveplan/testing-dom` render utilities and `@suaveplan/testing/runner` for the runner-neutral imports (CLAUDE.md rule 11).
|
||||
- For environments lacking `PublicKeyCredential` entirely (deliberately stripped), assert `REACT_AUTH.AUTOFILL_UNAVAILABLE` is reported via `onError` and `ready` stays `false`.
|
||||
- Lifecycle is verified by mounting, asserting an in-flight request, unmounting, and asserting the request rejected with `REACT_AUTH.ABORTED`.
|
||||
- 100% coverage per CLAUDE.md / openspec rule 7 — no carve-outs.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/auth/react-auth/
|
||||
README.md
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts # explicit named re-exports (rule 6a)
|
||||
use-passkey-autofill/
|
||||
index.ts
|
||||
use-passkey-autofill.ts
|
||||
use-passkey-autofill.md
|
||||
use-passkey-autofill.test.tsx
|
||||
use-passkey-autofill.stories.tsx
|
||||
use-passkey-autofill.types.ts
|
||||
use-passkey-autofill.schemas.ts
|
||||
passkey-autofill-field/
|
||||
index.ts
|
||||
passkey-autofill-field.tsx
|
||||
passkey-autofill-field.md
|
||||
passkey-autofill-field.test.tsx
|
||||
passkey-autofill-field.stories.tsx
|
||||
passkey-autofill-field.types.ts
|
||||
errors/
|
||||
index.ts
|
||||
errors.ts
|
||||
errors.md
|
||||
errors.test.ts
|
||||
utils/
|
||||
compose-signals.ts
|
||||
compose-signals.md
|
||||
compose-signals.test.ts
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Hook-first, component-optional.** The `<PasskeyAutofillField>` wrapper exists for the common case but is never a requirement; advanced consumers wire the hook to their own input.
|
||||
- **No global state, no context provider.** Multiple `usePasskeyAutofill` instances may coexist (e.g. a multi-tenant login page); each owns its own controller.
|
||||
- **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes. The underlying `WEBAUTHN.*` cause is preserved on `error.cause` for introspection.
|
||||
- **`onCredential` is required, `onError` is optional.** A consumer that does not pass `onError` accepts that errors silently no-op (with `error` still populated on the return value).
|
||||
- **Stable `abort` reference.** Returned via `useCallback` so consumers can put it in dependency arrays without thrashing.
|
||||
@@ -0,0 +1,85 @@
|
||||
# `@suaveplan/react-auth` — `usePasskeyAutofill` React hook
|
||||
|
||||
**Package**: `@suaveplan/react-auth` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Date**: 2026-05-17
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/react-auth/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The archived `enterprise-webauthn` change deferred a React hook target named `usePasskeyAutofill` to a follow-up `@suaveplan/react-auth` change (see `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` lines 85-86, 117, 191 and `tasks.md` §2.7.3 / §0 "Out of scope"). The underlying capability — `authenticate({ mediation: "conditional" })` plus `isConditionalMediationAvailable()` — shipped on `@suaveplan/webauthn` and is exported from `packages/auth/webauthn/src/authenticate/authenticate.ts` and `packages/auth/webauthn/src/index.ts`, so the consumer-side hook is now unblocked.
|
||||
|
||||
Without this hook, every genesis React app that wants WebAuthn Level 3 Conditional UI ("passkey autofill" — the browser surfaces credentials in the username `<input>` autocomplete sheet) has to hand-roll the same wiring: SSR guard, feature detect, `AbortController` lifecycle, unmount cancellation, blur cancellation, and error-code translation. Hand-rolled versions get the abort lifecycle wrong (they leak credential requests across mounts) or skip the `isConditionalMediationAvailable()` check (which throws on Safari < 17 and every SSR environment). Shipping the hook once, in a dedicated package, removes that footgun.
|
||||
|
||||
`@suaveplan/react-auth` does not exist yet. This change creates it as a lightweight browser-tier package with a single React 19 peer dep, exporting the hook and a tiny optional `<PasskeyAutofillField>` wrapper that renders an `<input autocomplete="username webauthn">` with the hook pre-wired. The package is deliberately scoped to the autofill surface only; future auth-related React hooks (passkey-as-master-key-unlock, OAuth callback, magic-link landing) land in follow-up changes under the same package.
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/react-auth`
|
||||
|
||||
Browser-tier package at `packages/auth/react-auth/`. React 19 peer dep, `@suaveplan/webauthn` workspace dep.
|
||||
|
||||
#### Hook
|
||||
|
||||
- ADDED: `usePasskeyAutofill(options: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn` — declarative hook around `authenticate({ mediation: "conditional" })`. Owns its own `AbortController`; aborts on unmount, on a fresh option-change remount, and (optionally) on the consumer-supplied `<input>`'s `blur` when no credential has been delivered.
|
||||
- ADDED: `UsePasskeyAutofillOptions` — `{ rpId: string; challenge: Uint8Array; onCredential: (result: AuthenticateResult) => void; onError?: (error: SuaveplanError) => void; signal?: AbortSignal; allowCredentials?: PublicKeyCredentialDescriptor[]; userVerification?: UserVerificationRequirement; timeout?: number; expectedOrigin?: string; expectedRpIdHash?: Uint8Array }`. All option fields are surfaced as a named interface (per CLAUDE.md rule 11 — no inline shapes).
|
||||
- ADDED: `UsePasskeyAutofillReturn` — `{ ready: boolean; error: SuaveplanError | null; abort: () => void }`. `ready` is `true` once `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` call has been dispatched. `error` is the most recent translated error or `null`. `abort()` is a stable callback that cancels the in-flight request without unmounting the component.
|
||||
|
||||
#### Optional component wrapper
|
||||
|
||||
- ADDED: `<PasskeyAutofillField>` — a thin presentational `<input autocomplete="username webauthn">` wrapper that wires the hook to the input and forwards `onBlur` to the hook's `abort`. Accepts `inputProps` so the consumer can style and label the input freely. The component is opt-in: callers may instead use the hook directly with their own `<input>` markup.
|
||||
- ADDED: `PasskeyAutofillFieldProps` — named interface for the component's prop shape.
|
||||
|
||||
#### Errors / observability / boundaries
|
||||
|
||||
- ADDED: `defineCodes("REACT_AUTH", [...])` registry covering `REACT_AUTH.AUTOFILL_UNAVAILABLE` (browser missing `isConditionalMediationAvailable`), `REACT_AUTH.AUTOFILL_FAILED` (underlying `WEBAUTHN.*` cause), `REACT_AUTH.ABORTED` (caller-driven abort), `REACT_AUTH.SSR_NO_OP` (hook ran on server — reported via `onError` only when `onError` is supplied, otherwise silently no-ops), `REACT_AUTH.INVALID_OPTIONS` (Zod boundary rejection of `UsePasskeyAutofillOptions`). Every code is thrown by exactly one production path.
|
||||
- ADDED: `UsePasskeyAutofillOptionsSchema` — Zod schema validated at hook entry; mismatches throw `REACT_AUTH.INVALID_OPTIONS` synchronously (so React surfaces it via the nearest error boundary at mount time, not silently in an effect).
|
||||
- ADDED: `AbortSignal` chaining — caller-supplied `options.signal` is composed with the hook's internal controller so either source aborts the underlying `navigator.credentials.get`. Internal controller fires on unmount and on `abort()`.
|
||||
|
||||
#### SSR + feature detection
|
||||
|
||||
- ADDED: `useEffect`-gated entry point — the hook performs no work on the server render. On the first client mount it calls `PublicKeyCredential.isConditionalMediationAvailable?.()` via `@suaveplan/webauthn`'s `isConditionalMediationAvailable` helper. If the static method is missing or returns `false`, the hook stays `ready === false`, emits `REACT_AUTH.AUTOFILL_UNAVAILABLE` via `onError` (if supplied), and never dispatches `authenticate`.
|
||||
- ADDED: stable `abort` reference — returned `abort` is wrapped with `useCallback` and a controller ref, so consumers can put it in `useEffect` cleanup arrays without re-running the effect on every render.
|
||||
|
||||
#### Storybook
|
||||
|
||||
- ADDED: `src/use-passkey-autofill/use-passkey-autofill.stories.tsx` — default story (hook wired into a dummy form), playground story (props as controls), variant stories (unavailable browser, abort-on-blur, error-state). Per CLAUDE.md "Every React component needs `.stories.tsx`".
|
||||
- ADDED: `src/passkey-autofill-field/passkey-autofill-field.stories.tsx` — default / playground / variants.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
- `.` — root barrel: `usePasskeyAutofill`, `UsePasskeyAutofillOptions`, `UsePasskeyAutofillReturn`, `PasskeyAutofillField`, `PasskeyAutofillFieldProps`, error registry.
|
||||
- `./use-passkey-autofill/use-passkey-autofill` — hook + types.
|
||||
- `./passkey-autofill-field/passkey-autofill-field` — component + props.
|
||||
- `./errors` — `REACT_AUTH` code registry.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@suaveplan/webauthn workspace:^` — `authenticate`, `isConditionalMediationAvailable`, `AuthenticateResult`.
|
||||
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
|
||||
- `@suaveplan/types workspace:^` — shared option bundles.
|
||||
- `react ^19.0.0` (peer) — hook surface.
|
||||
- `zod catalog:forms` — boundary validation.
|
||||
- `tslib catalog:build`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Server-side challenge issuance / verification** — `@suaveplan/api-schemas/auth` and `@suaveplan/vault` own that. The hook expects the caller to have already obtained `{ rpId, challenge }` from the server.
|
||||
- **Non-autofill WebAuthn React hooks** — `usePasskeyRegister`, `usePasskeyAuthenticate` (modal), `useVaultUnlock` land in follow-up changes under `@suaveplan/react-auth`.
|
||||
- **WebAuthn Level 2 polyfill** — Conditional UI is L3-only; older browsers surface `REACT_AUTH.AUTOFILL_UNAVAILABLE` and the hook no-ops.
|
||||
- **DOM manipulation** — the hook does not render or query DOM elements directly. The consumer renders the `<input autocomplete="username webauthn">`. The optional `<PasskeyAutofillField>` is a thin convenience wrapper, not a requirement.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
This is a NEW package — no existing dependents. Run `bunx turbo ls --filter=...@suaveplan/react-auth` after landing to confirm. The hook will be adopted by `@suaveplan/web-app` and any genesis-original app that ships a login page, in their own follow-up changes.
|
||||
|
||||
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/react-auth` post-landing — currently empty).
|
||||
|
||||
## Migration
|
||||
|
||||
No prior surface — additive only. There is no obsolete contract for consumers to migrate from. The `enterprise-webauthn` change explicitly scoped this hook out with the note "implementation lives in the `react-auth` change folder; this package only ships the underlying `authenticate({ mediation: "conditional" })` surface" — this change fulfils that deferral.
|
||||
@@ -0,0 +1,186 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: react-auth (add-react-auth-passkey-autofill)
|
||||
|
||||
**Package**: `@suaveplan/react-auth`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: usePasskeyAutofill hook dispatches conditional WebAuthn
|
||||
|
||||
The package SHALL export a `usePasskeyAutofill` React hook that, on the first client mount, feature-detects WebAuthn Conditional UI via `isConditionalMediationAvailable()` and dispatches `authenticate({ mediation: "conditional" })` via `@suaveplan/webauthn`. The hook SHALL deliver a successful `AuthenticateResult` via the caller's `onCredential` callback.
|
||||
|
||||
#### Scenario: conditional authenticate is invoked when supported
|
||||
|
||||
- **WHEN** the hook mounts in a browser whose `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`
|
||||
- **THEN** the hook calls `authenticate` with `mediation: "conditional"` and the caller-supplied `rpId`, `challenge`, `allowCredentials`, `userVerification`, `timeout`, `expectedOrigin`, and `expectedRpIdHash`
|
||||
- **AND** the returned `ready` flag flips to `true` once the request is in-flight
|
||||
|
||||
#### Scenario: successful credential is delivered to onCredential
|
||||
|
||||
- **WHEN** the underlying `authenticate` Promise resolves with an `AuthenticateResult`
|
||||
- **THEN** `onCredential` is invoked once with that result
|
||||
- **AND** the hook's `error` state remains `null`
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"dispatches authenticate with mediation conditional when available"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"delivers AuthenticateResult to onCredential on success"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Hook is SSR-safe and feature-detects before dispatch
|
||||
|
||||
The hook SHALL perform zero work during server rendering and SHALL never call `navigator.credentials.get` without first confirming `PublicKeyCredential.isConditionalMediationAvailable()` resolves `true`. When Conditional UI is unavailable, the hook SHALL stay `ready === false` and report `REACT_AUTH.AUTOFILL_UNAVAILABLE` via the caller's `onError` (when supplied).
|
||||
|
||||
#### Scenario: server render performs no DOM access
|
||||
|
||||
- **WHEN** the hook is rendered in an environment where `typeof window === "undefined"`
|
||||
- **THEN** the hook returns `{ ready: false, error: null, abort }` without touching `navigator`, `document`, or `PublicKeyCredential`
|
||||
|
||||
#### Scenario: missing Conditional UI surfaces AUTOFILL_UNAVAILABLE
|
||||
|
||||
- **WHEN** the hook mounts in a browser where `PublicKeyCredential.isConditionalMediationAvailable` is undefined OR resolves `false`
|
||||
- **THEN** the hook never calls `authenticate`
|
||||
- **AND** `ready` remains `false`
|
||||
- **AND** `onError` (if supplied) is invoked with a `SuaveplanError` whose `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"`
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"performs no work during SSR render"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"reports AUTOFILL_UNAVAILABLE when isConditionalMediationAvailable is missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortController is owned by the hook and aborts on unmount, signal, and abort()
|
||||
|
||||
The hook SHALL own exactly one internal `AbortController` per effect run, composed with the caller's `options.signal` if supplied. The composed signal SHALL be forwarded into the underlying `authenticate` call. The hook SHALL abort the in-flight request when the component unmounts, when the caller's `signal` aborts, or when the consumer invokes the returned `abort()` callback.
|
||||
|
||||
#### Scenario: unmount aborts the in-flight authenticate call
|
||||
|
||||
- **WHEN** the hook is mounted with an in-flight `authenticate` request and the component unmounts
|
||||
- **THEN** the underlying `navigator.credentials.get` rejects with `AbortError`
|
||||
- **AND** the hook's `error` (read before unmount completes) carries a `SuaveplanError` whose `code === "REACT_AUTH.ABORTED"` with the `WEBAUTHN.ABORTED` cause preserved
|
||||
|
||||
#### Scenario: caller-supplied AbortSignal aborts the request
|
||||
|
||||
- **WHEN** the caller passes `options.signal` and that controller aborts after mount
|
||||
- **THEN** the in-flight `authenticate` rejects with `AbortError`
|
||||
- **AND** the hook reports `REACT_AUTH.ABORTED` via `onError` if supplied
|
||||
|
||||
#### Scenario: returned abort() cancels without unmount
|
||||
|
||||
- **WHEN** the consumer calls the returned `abort()` while a request is in-flight
|
||||
- **THEN** the request is cancelled
|
||||
- **AND** the component remains mounted
|
||||
- **AND** `ready` returns to `false`
|
||||
|
||||
#### Scenario: option-identity change cancels old request and dispatches new
|
||||
|
||||
- **WHEN** `options.challenge` reference changes between renders
|
||||
- **THEN** the old controller aborts and a new `authenticate` is dispatched against the new challenge
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"aborts the in-flight request on unmount"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"propagates caller AbortSignal to authenticate"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort cancels without unmounting"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"re-dispatches when challenge identity changes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WebAuthn errors are translated into the REACT_AUTH code registry
|
||||
|
||||
The package SHALL export a `defineCodes("REACT_AUTH", ...)` registry covering `AUTOFILL_UNAVAILABLE`, `AUTOFILL_FAILED`, `ABORTED`, `SSR_NO_OP`, and `INVALID_OPTIONS`. Every error surfaced by the hook SHALL be a `SuaveplanError` whose `code` is one of these values; the underlying `WEBAUTHN.*` error SHALL be preserved on `cause` for introspection.
|
||||
|
||||
#### Scenario: WEBAUTHN.NOT_SUPPORTED maps to REACT_AUTH.AUTOFILL_UNAVAILABLE
|
||||
|
||||
- **WHEN** the underlying `authenticate` rejects with `WEBAUTHN.NOT_SUPPORTED`
|
||||
- **THEN** the hook surfaces a `SuaveplanError` with `code === "REACT_AUTH.AUTOFILL_UNAVAILABLE"` and `cause.code === "WEBAUTHN.NOT_SUPPORTED"`
|
||||
|
||||
#### Scenario: WEBAUTHN.ABORTED maps to REACT_AUTH.ABORTED
|
||||
|
||||
- **WHEN** the underlying request rejects with `WEBAUTHN.ABORTED`
|
||||
- **THEN** the surfaced error has `code === "REACT_AUTH.ABORTED"` with the same cause preserved
|
||||
|
||||
#### Scenario: any other WEBAUTHN.* maps to REACT_AUTH.AUTOFILL_FAILED
|
||||
|
||||
- **WHEN** the underlying request rejects with `WEBAUTHN.SIGNATURE_INVALID` (or any other non-cancel, non-not-supported `WEBAUTHN.*` code)
|
||||
- **THEN** the surfaced error has `code === "REACT_AUTH.AUTOFILL_FAILED"` with the underlying cause preserved
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.NOT_SUPPORTED to REACT_AUTH.AUTOFILL_UNAVAILABLE"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates WEBAUTHN.ABORTED to REACT_AUTH.ABORTED"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"translates other WEBAUTHN errors to REACT_AUTH.AUTOFILL_FAILED"` AND `src/errors/errors.test.ts::"every REACT_AUTH code is reachable from a production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Hook options are validated by Zod at entry
|
||||
|
||||
The hook SHALL validate `UsePasskeyAutofillOptions` against a Zod schema at the start of every effect run. Schema mismatches SHALL throw `REACT_AUTH.INVALID_OPTIONS` synchronously so that React's nearest error boundary catches the failure at mount time rather than silently ignoring it inside an effect.
|
||||
|
||||
#### Scenario: invalid rpId throws INVALID_OPTIONS
|
||||
|
||||
- **WHEN** the caller passes `rpId: ""` (empty string) or omits a required field
|
||||
- **THEN** the hook throws a `SuaveplanError` with `code === "REACT_AUTH.INVALID_OPTIONS"` during render
|
||||
- **AND** no `authenticate` call is dispatched
|
||||
|
||||
#### Scenario: invalid challenge type throws INVALID_OPTIONS
|
||||
|
||||
- **WHEN** `challenge` is not a `Uint8Array`
|
||||
- **THEN** the same error is thrown synchronously
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when rpId is empty"` AND `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"throws INVALID_OPTIONS when challenge is not Uint8Array"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Hook does not touch the DOM directly
|
||||
|
||||
The hook SHALL NOT call `document.querySelector`, attach event listeners to inputs, or render any DOM. The autofill UI is surfaced by the browser based on an `<input autocomplete="username webauthn">` element rendered by the consumer; the hook only keeps the conditional `authenticate` request pending so the browser has something to deliver into.
|
||||
|
||||
#### Scenario: hook makes no document or input access
|
||||
|
||||
- **WHEN** the hook is mounted with a spy installed on `document.querySelector` and `HTMLInputElement.prototype.addEventListener`
|
||||
- **THEN** neither spy is invoked during the hook's lifecycle
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"does not touch document or input elements"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Returned abort callback has a stable reference
|
||||
|
||||
The hook SHALL return an `abort` callback whose reference identity is stable across renders (i.e. wrapped with `useCallback` against a controller ref), so consumers can place it in `useEffect` dependency arrays or memoization keys without triggering re-runs.
|
||||
|
||||
#### Scenario: abort reference is identical across re-renders
|
||||
|
||||
- **WHEN** the parent component re-renders while the hook's options are unchanged
|
||||
- **THEN** `Object.is(prevReturn.abort, nextReturn.abort) === true`
|
||||
|
||||
**Verified by:** `src/use-passkey-autofill/use-passkey-autofill.test.tsx::"returned abort reference is stable across re-renders"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PasskeyAutofillField component wraps the hook with an input
|
||||
|
||||
The package SHALL export an optional `<PasskeyAutofillField>` component that renders an `<input autocomplete="username webauthn">` element, wires it to `usePasskeyAutofill`, and (by default) calls the hook's `abort()` when the input fires `blur` AND no credential has been delivered yet. Consumers SHALL be able to forward arbitrary `inputProps` to style and label the input.
|
||||
|
||||
#### Scenario: input renders with the correct autocomplete token
|
||||
|
||||
- **WHEN** `<PasskeyAutofillField rpId="example.test" challenge={c} onCredential={...} />` is rendered
|
||||
- **THEN** the rendered `<input>` has `autocomplete="username webauthn"`
|
||||
|
||||
#### Scenario: blur cancels the request when no credential delivered
|
||||
|
||||
- **WHEN** the user blurs the input before any credential is delivered
|
||||
- **THEN** the hook's `abort()` is invoked and the in-flight request rejects with `REACT_AUTH.ABORTED`
|
||||
|
||||
#### Scenario: blur after credential delivery does not abort
|
||||
|
||||
- **WHEN** `onCredential` has already fired
|
||||
- **THEN** a subsequent blur does NOT invoke `abort()`
|
||||
|
||||
**Verified by:** `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"renders input with autocomplete username webauthn"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur cancels in-flight request"` AND `src/passkey-autofill-field/passkey-autofill-field.test.tsx::"blur after credential delivery is a no-op"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports cover hook, component, and errors
|
||||
|
||||
The package SHALL expose subpath exports at `.`, `./use-passkey-autofill/use-passkey-autofill`, `./passkey-autofill-field/passkey-autofill-field`, and `./errors`, each tree-shakeable, so consumers can import the hook, the component, or the error registry independently.
|
||||
|
||||
#### Scenario: every documented subpath resolves
|
||||
|
||||
- **WHEN** a consumer imports each documented subpath
|
||||
- **THEN** every import resolves to the declared symbols
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every documented subpath resolves"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,104 @@
|
||||
# Tasks — `@suaveplan/react-auth` `usePasskeyAutofill`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes.
|
||||
|
||||
## 0. Blocking prerequisite
|
||||
|
||||
- [x] 0.1 `@suaveplan/webauthn` exports `authenticate({ mediation: "conditional" })` and `isConditionalMediationAvailable()` — verified at `packages/auth/webauthn/src/authenticate/authenticate.ts` and `packages/auth/webauthn/src/index.ts`.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` + `tasks.md` to confirm the `usePasskeyAutofill` deferral
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` §13 and §14
|
||||
- [x] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.4 Draft `design.md` covering hook signature, lifecycle, SSR safety, error translation
|
||||
- [x] 1.5 Draft `specs/react-auth/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.6 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [ ] 2.1 Create `packages/auth/react-auth/` with `package.json` (browser tier, React 19 peer, `@suaveplan/webauthn workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `zod catalog:forms`, `tslib catalog:build`, `@vitest/coverage-istanbul catalog:testing`)
|
||||
- [ ] 2.2 `tsconfig.json` extending `@suaveplan/typescript-config/browser`
|
||||
- [ ] 2.3 `vitest.config.ts` via `createTestConfig` with jsdom environment and 100/100/100/100 thresholds
|
||||
- [ ] 2.4 `package.json` `exports` map covering `.`, `./use-passkey-autofill/use-passkey-autofill`, `./passkey-autofill-field/passkey-autofill-field`, `./errors`
|
||||
- [ ] 2.5 `"sideEffects": false`, `"browser"` field set
|
||||
|
||||
## 3. Implementation
|
||||
|
||||
### 3.1 Errors
|
||||
|
||||
- [ ] 3.1.1 `src/errors/errors.ts` — `defineCodes("REACT_AUTH", ["AUTOFILL_UNAVAILABLE", "AUTOFILL_FAILED", "ABORTED", "SSR_NO_OP", "INVALID_OPTIONS"])`
|
||||
|
||||
### 3.2 Utilities
|
||||
|
||||
- [ ] 3.2.1 `src/utils/compose-signals.ts` — composes N `AbortSignal`s into one (any aborts → composed aborts); handles already-aborted signals
|
||||
|
||||
### 3.3 Hook
|
||||
|
||||
- [ ] 3.3.1 `src/use-passkey-autofill/use-passkey-autofill.types.ts` — named `UsePasskeyAutofillOptions`, `UsePasskeyAutofillReturn` interfaces
|
||||
- [ ] 3.3.2 `src/use-passkey-autofill/use-passkey-autofill.schemas.ts` — `UsePasskeyAutofillOptionsSchema` Zod schema; throws `REACT_AUTH.INVALID_OPTIONS` on parse failure
|
||||
- [ ] 3.3.3 `src/use-passkey-autofill/use-passkey-autofill.ts` — hook body: SSR no-op, feature-detect, dispatch, AbortController ownership, stable `abort` callback, error translation
|
||||
- [ ] 3.3.4 Effect re-runs on option-identity change; old controller aborts cleanly
|
||||
|
||||
### 3.4 Component wrapper
|
||||
|
||||
- [ ] 3.4.1 `src/passkey-autofill-field/passkey-autofill-field.types.ts` — named `PasskeyAutofillFieldProps`
|
||||
- [ ] 3.4.2 `src/passkey-autofill-field/passkey-autofill-field.tsx` — wraps hook + `<input autocomplete="username webauthn">`; forwards `onBlur` → `abort()`
|
||||
|
||||
### 3.5 Barrels
|
||||
|
||||
- [ ] 3.5.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [ ] 3.5.2 Per-feature `index.ts` files — explicit named re-exports
|
||||
|
||||
## 4. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
|
||||
|
||||
- [ ] 4.1 `src/use-passkey-autofill/use-passkey-autofill.test.tsx` — happy path, SSR no-op, feature-unavailable, abort on unmount, abort on caller signal, abort via returned callback, option-identity change re-runs, error translation for every `WEBAUTHN.*` code, Zod rejection at entry
|
||||
- [ ] 4.2 `src/passkey-autofill-field/passkey-autofill-field.test.tsx` — renders input with correct autocomplete, forwards `inputProps`, blur calls `abort` only when no credential delivered
|
||||
- [ ] 4.3 `src/errors/errors.test.ts` — every `REACT_AUTH.*` code reachable from a production path
|
||||
- [ ] 4.4 `src/utils/compose-signals.test.ts` — any-aborts semantics, already-aborted inputs, no leaks
|
||||
- [ ] 4.5 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async)
|
||||
|
||||
## 5. Stories
|
||||
|
||||
- [ ] 5.1 `src/use-passkey-autofill/use-passkey-autofill.stories.tsx` — default, playground, variants (unavailable, error, abort-on-blur)
|
||||
- [ ] 5.2 `src/passkey-autofill-field/passkey-autofill-field.stories.tsx` — default, playground, variants
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [ ] 6.1 Sibling `.md` for `use-passkey-autofill.ts`, `passkey-autofill-field.tsx`, `errors.ts`, `compose-signals.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 6.2 `README.md` at package root with Modules table
|
||||
- [ ] 6.3 Add `@suaveplan/react-auth` row to `CLAUDE.md` under an Auth/React section
|
||||
|
||||
## 7. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 7.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
|
||||
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green
|
||||
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100/100/100/100
|
||||
- [ ] 7.4 `bun scripts/openspec-conformance.ts @suaveplan/react-auth` — exit 0, no `error` findings
|
||||
- [ ] 7.5 `bunx turbo build --filter=@suaveplan/react-auth` — green
|
||||
|
||||
## 8. Pre-archive 7-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 8.1 Generics threaded; `expectTypeOf` assertions on hook return type
|
||||
- [ ] 8.2 Zod schema wired at hook entry boundary
|
||||
- [ ] 8.3 Every `REACT_AUTH.*` code thrown by ≥1 production path
|
||||
- [ ] 8.4 Strict-mode behaviors tested (feature-available vs unavailable)
|
||||
- [ ] 8.5 Hook lifecycle tested under React 19 strict-mode double-mount
|
||||
- [ ] 8.6 AbortSignal composition tested for all sources (caller signal, returned `abort`, unmount)
|
||||
- [ ] 8.7 No retroactive tick
|
||||
|
||||
## 9. Banned-pattern + barrel + runner-import scans
|
||||
|
||||
- [ ] 9.1 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/auth/react-auth/src/ --include="*.ts" --include="*.tsx"` — zero output
|
||||
- [ ] 9.2 `grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" packages/auth/react-auth/src/ --include="index.ts"` — zero output (feature barrels must use named exports)
|
||||
- [ ] 9.3 `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/auth/react-auth/src/ --include="*.ts" --include="*.tsx"` — zero output
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [ ] 10.1 Fold `specs/react-auth/spec.md` into `openspec/specs/react-auth/spec.md`
|
||||
- [ ] 10.2 Move `openspec/changes/add-react-auth-passkey-autofill/` → `openspec/archive/<yyyy-mm-dd>-add-react-auth-passkey-autofill/`
|
||||
- [ ] 10.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 10.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts`
|
||||
- [ ] 10.5 Update `STATUS.md` — add `@suaveplan/react-auth` row in `Completed`
|
||||
- [ ] 10.6 Update `SUMMARY.md` with per-package paragraph
|
||||
- [ ] 10.7 `bunx markdownlint-cli2 --fix` and stage results
|
||||
@@ -0,0 +1,173 @@
|
||||
# Design — @suaveplan/storage-mux
|
||||
|
||||
Non-normative companion to `proposal.md` and `specs/storage-mux/spec.md`.
|
||||
|
||||
## 1. Role in the family
|
||||
|
||||
```text
|
||||
@suaveplan/storage AbstractStorage core (key/value, in-memory)
|
||||
├─ @suaveplan/storage-cdn CdnAdapter contract
|
||||
│ ├─ @suaveplan/storage-cdn-bunnycdn
|
||||
│ └─ @suaveplan/storage-cdn-rackspace
|
||||
├─ @suaveplan/storage-s3
|
||||
├─ @suaveplan/storage-http
|
||||
├─ @suaveplan/storage-file
|
||||
└─ @suaveplan/storage-mux Multi-backend composition (THIS PACKAGE)
|
||||
└─ wraps N StorageLike, exposes 1 StorageLike
|
||||
```
|
||||
|
||||
`storage-mux` is a **composition** package, not a transport. It accepts any list of `StorageLike` (cdn / s3 / http / file / memory / other mux instances) and produces a single `StorageLike`. Recursion is intentional — a sharded-then-failed-over deployment is `Mux(round-robin, [Mux(failover, shard1), Mux(failover, shard2)])`.
|
||||
|
||||
## 2. File layout
|
||||
|
||||
```text
|
||||
src/
|
||||
index.ts # explicit named re-exports
|
||||
error-codes.ts # defineCodes("STORAGE_MUX", [...]) + StorageMuxError
|
||||
types/
|
||||
backend-entry.types.ts # BackendEntry interface
|
||||
mux-options.types.ts # StorageMuxOptions, MuxPolicy union
|
||||
mux-options.schemas.ts # Zod boundary schemas
|
||||
mux/
|
||||
mux.ts # StorageMux extends AbstractStorage
|
||||
mux.md
|
||||
mux.test.ts
|
||||
mux.spec-conformance.test.ts # runStorageConformance × 4 policies
|
||||
policies/
|
||||
selector.types.ts # BackendSelector interface
|
||||
failover/
|
||||
failover.ts
|
||||
failover.md
|
||||
failover.test.ts
|
||||
round-robin/
|
||||
round-robin.ts
|
||||
round-robin.md
|
||||
round-robin.test.ts
|
||||
sticky-by-key/
|
||||
sticky-by-key.ts # stable hash → backend index
|
||||
sticky-by-key.md
|
||||
sticky-by-key.test.ts
|
||||
shield/
|
||||
shield.ts # reads cascade, writes fan-out
|
||||
shield.md
|
||||
shield.test.ts
|
||||
health/
|
||||
health.ts # BackendHealth tracker + cooldown
|
||||
health.md
|
||||
health.test.ts
|
||||
observability/
|
||||
events.ts # event topic constants + emit helpers
|
||||
events.md
|
||||
events.test.ts
|
||||
otel.ts # span attribute helpers
|
||||
otel.md
|
||||
otel.test.ts
|
||||
error-codes/
|
||||
error-codes.test.ts # reachability sweep
|
||||
```
|
||||
|
||||
## 3. Policy state machines
|
||||
|
||||
### 3.1 `failover`
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────┐
|
||||
▼ │
|
||||
[idx=0] ─ call backend[idx] ── ok ──► return │
|
||||
│ │
|
||||
├── transient error ─────► idx++ ──────────┘
|
||||
│
|
||||
├── non-transient error ──► throw (surface as-is)
|
||||
│
|
||||
└── idx == backends.length ─► throw STORAGE_MUX.ALL_BACKENDS_DOWN
|
||||
(cause = last transient error)
|
||||
```
|
||||
|
||||
Per-attempt: `runWithRetry(retryStrategy, () => backend[idx].op(...))` so transient errors get the configured retry inside a single backend before falling through.
|
||||
|
||||
### 3.2 `round-robin`
|
||||
|
||||
Atomic counter modulo `healthyBackends.length`. On `ALL_BACKENDS_DOWN`, returns the failover error. Down backends skipped via `BackendHealth.isUp(name)`.
|
||||
|
||||
### 3.3 `sticky-by-key`
|
||||
|
||||
```ts
|
||||
const h = stableHash(key) % healthyBackends.length;
|
||||
return healthyBackends[h];
|
||||
```
|
||||
|
||||
Hash is FNV-1a over UTF-8 bytes (universal, no crypto dep). When a backend goes down, the modulo redistributes — same key may move to a different backend; this is acceptable for cache workloads and documented.
|
||||
|
||||
### 3.4 `shield`
|
||||
|
||||
```text
|
||||
read: backend[0] (origin) ?? backend[1..n] (edge mirrors) ── first success wins
|
||||
write: Promise.allSettled(backend[0..n].set(...))
|
||||
├── all ok ─► return
|
||||
├── primary ok, mirror failed ─► return + emit WRITE_FANOUT_PARTIAL event
|
||||
└── primary failed ─► throw (cause = primary error)
|
||||
```
|
||||
|
||||
Distinct from `failover` because `shield` reads **must** hit origin first (origin is canonical), and writes **must** fan out (mirrors only serve what was written to them). `failover` is symmetric — any backend can serve any operation.
|
||||
|
||||
## 4. Error classification
|
||||
|
||||
Which errors trigger failover vs surface directly:
|
||||
|
||||
| Error shape | Classification | Triggers fall-through? | Code surfaced |
|
||||
|---|---|---|---|
|
||||
| `AbortError` / `signal.aborted` | non-transient | NO | `STORAGE_MUX.BACKEND_ABORTED` |
|
||||
| `STORAGE.KEY_NOT_FOUND` (from `get`/`stat`) | semantic | NO | rethrow as-is |
|
||||
| `STORAGE.INVALID_KEY` | semantic | NO | rethrow as-is |
|
||||
| Network timeout (configurable `backendTimeoutMs`) | transient | YES | wrapped, last in cause chain |
|
||||
| `ECONNRESET` / `ETIMEDOUT` / fetch network error | transient | YES | wrapped |
|
||||
| HTTP 5xx from backend | transient | YES | wrapped |
|
||||
| HTTP 4xx (except 408/429) | non-transient | NO | rethrow |
|
||||
| HTTP 408 / 429 | transient | YES | wrapped |
|
||||
| `STORAGE_CDN.CREDENTIAL_EXPIRED` | non-transient | NO | rethrow (caller must rotate) |
|
||||
| Unknown thrown value (non-Error) | transient | YES | wrapped |
|
||||
|
||||
Classification lives in `src/policies/classify.ts`, pure function over `(error: unknown) => "transient" | "semantic" | "abort"`. Tested independently.
|
||||
|
||||
## 5. Backend-isolation guarantees
|
||||
|
||||
A slow primary MUST NOT block a fallback. Mechanism:
|
||||
|
||||
1. **Per-backend timeout** — every backend call wrapped in `Promise.race([call, timeoutPromise(backendTimeoutMs)])`. Default 30 s, configurable per-mux.
|
||||
2. **AbortSignal forwarding** — the per-attempt signal is linked (`AbortSignal.any([userSignal, timeoutSignal])`) and passed to the backend. A well-behaved backend respects it; a misbehaved backend will eventually reject via the timeout race.
|
||||
3. **`shield` reads cascade sequentially with timeout** — origin gets `backendTimeoutMs`; on timeout/error, the mux moves to mirror[0], etc. The next backend's call starts before the previous backend's underlying promise has settled (it is left to its own cleanup).
|
||||
4. **`shield` writes are `Promise.allSettled`** — one slow mirror cannot starve the others. Caller sees the result when all settle OR `backendTimeoutMs * 2` elapses (guard against a permanently-hung mirror).
|
||||
|
||||
The mux NEVER waits for a hung promise indefinitely — `Promise.race` with a clock-driven timeout is the contract.
|
||||
|
||||
## 6. Health tracking
|
||||
|
||||
`BackendHealth` per-backend state:
|
||||
|
||||
```ts
|
||||
interface BackendState {
|
||||
status: "up" | "down";
|
||||
consecutiveFailures: number;
|
||||
cooldownUntil: number; // Clock.now() millis
|
||||
lastError?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
Transitions:
|
||||
|
||||
- **up → down**: triggered by any classified-transient error. `cooldownUntil = now + cooldownMs * 2 ** min(consecutiveFailures, 6)` (exponential capped at 64× base). Emits `storage-mux.backend.down`.
|
||||
- **down → up**: triggered when `now >= cooldownUntil` AND the next selection call probes the backend AND the probe succeeds. Resets `consecutiveFailures`. Emits `storage-mux.backend.recovered`.
|
||||
- **active healthcheck**: if `BackendEntry.healthcheck` is provided AND `healthcheckIntervalMs` is set, the mux schedules `Clock.setTimeout` to invoke it; failure marks the backend down without waiting for a real request.
|
||||
|
||||
`Clock` is injected (`@suaveplan/types#Clock`), so tests use `FakeClock` for deterministic cooldown / healthcheck advancement.
|
||||
|
||||
## 7. Tier purity
|
||||
|
||||
Universal — no `node:*`, no `bun:*`, no DOM. Hash uses pure-JS FNV-1a. Timeouts use injected `Clock`. AbortSignal is standard Web API. Conformance harness runs under Node, Bun, browsers, Workers.
|
||||
|
||||
## 8. Trade-offs
|
||||
|
||||
- **Strategy classes vs. switch statement.** Chose strategy classes (`BackendSelector` interface). Each policy is a separate file ≤ 250 lines, individually tested, and new policies (e.g. `weighted-random` later) plug in without editing the core `StorageMux`.
|
||||
- **FNV-1a vs. xxhash for `sticky-by-key`.** FNV-1a is 20 lines of pure JS with no dep; xxhash needs a wasm or noble dep. For backend selection (low-cardinality output), FNV's distribution is sufficient. If a future workload needs better distribution, the hash function is injectable via options.
|
||||
- **`shield` writes fan out vs. primary-only.** Fan-out is the right default because mirrors that don't see writes can't serve reads. Callers who want primary-only writes should use `failover` instead — the policy choice is the API.
|
||||
- **Recursive Mux.** Allowed because `StorageMux` is itself a `StorageLike`. The conformance harness verifies recursion does not break invariants. The cost — debugging a deeply-nested mux tree — is mitigated by per-call OTel span attributes naming every backend hop.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Add @suaveplan/storage-mux — multi-backend storage failover / shielding mux
|
||||
|
||||
**Package**: `@suaveplan/storage-mux`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-mux/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/storage-cdn` (archived 2026-05-16) explicitly deferred origin shielding and multi-origin failover as out-of-scope, naming `storage-mux` as the future home (`openspec/archive/2026-05-16-add-storage-cdn/proposal.md` "Out of scope": *"Origin shielding / multi-origin failover. A failover/shielding layer is a separate concern composed via `storage-mux` (future) or application logic."*). The deferral was correct — failover semantics are orthogonal to CDN-specific surface (purge/signing/edgeUrl) and belong in a composable layer over the bare `AbstractStorage` contract.
|
||||
|
||||
`storage-mux` is a **universal-tier composition layer**: it accepts an ordered list of `StorageLike` backends and a policy (`failover` / `round-robin` / `sticky-by-key` / `shield`) and exposes a single `StorageLike` to consumers. Backends can be any storage adapter — `storage-cdn-*`, `storage-s3`, `storage-http`, `storage-file`, in-memory — making the mux the canonical primitive for CDN failover, origin shielding, mirror reads, and shard distribution.
|
||||
|
||||
### ≥2-consumer justification (per `openspec/AGENTS.md` §0 step 6)
|
||||
|
||||
Three forward-declared scenarios consume `storage-mux` directly:
|
||||
|
||||
1. **CDN failover for user-uploaded content** — primary `storage-cdn-bunnycdn` with `storage-cdn-rackspace` fallback. When BunnyCDN edge degrades, reads transparently fall through. The `failover` policy.
|
||||
2. **Cache mirroring for `storage-plugin-cache`** — local `storage-file` cache layered in front of remote `storage-s3` origin via the `shield` policy. Hits the fast tier first, falls through to origin on miss, writes to both.
|
||||
3. **Drive chunk shielding** — `drive-plugin-chunks` reads chunks from a regional mirror set with `sticky-by-key` so the same chunk always hits the same backend for cache warmth, plus failover when a mirror is down.
|
||||
|
||||
All three scenarios share the same primitive (ordered backend list + policy + health), differing only in policy. Without a shared mux, each consumer reinvents the failover state machine and the registry of `STORAGE_MUX.*` codes would be scattered.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `StorageMux` class extending `AbstractStorage` from `@suaveplan/storage`. Wraps an ordered backend list and a policy and exposes the full `StorageLike` surface (`get`, `set`, `setPartial`, `has`, `delete`, `list`, `stat`, `getStream`, `setStream`).
|
||||
- ADDED: `MuxPolicy` discriminated union — `"failover" | "round-robin" | "sticky-by-key" | "shield"`. Each policy is implemented as a strategy class behind a common `BackendSelector` interface.
|
||||
- ADDED: `BackendEntry` interface — `{ name: string; storage: StorageLike; weight?: number; healthcheck?: (signal: AbortSignal) => Promise<void> }`.
|
||||
- ADDED: `BackendHealth` tracker — passive degrade (mark down on transient error, exponential cooldown via injected `Clock`) + optional active polling via `healthcheck` callbacks scheduled on `Clock`.
|
||||
- ADDED: `StorageMuxOptions` — `{ backends: BackendEntry[]; policy: MuxPolicy; retry?: RetryStrategy; clock?: Clock; events?: EventBusLike; logger?: LoggerLike; tracer?: TracerLike; cooldownMs?: number; healthcheckIntervalMs?: number }`.
|
||||
- ADDED: `STORAGE_MUX_CODES` via `defineCodes("STORAGE_MUX", [...])` — 8 codes: `ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`.
|
||||
- ADDED: `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with `backendName`, `attemptIndex`, `attemptCount` discriminants.
|
||||
- ADDED: Retry composition — every backend call routed through `@suaveplan/retry` (`runWithRetry` + injected strategy). The mux never reimplements backoff; it composes.
|
||||
- ADDED: AbortSignal threaded through every backend call. A caller-aborted operation must surface `BACKEND_ABORTED`, not `ALL_BACKENDS_DOWN`.
|
||||
- ADDED: Observability — `EventBusLike` emissions on backend down/recovery/failover; OTel span attributes per call (`mux.backend.name`, `mux.attempt.index`, `mux.policy`).
|
||||
- ADDED: Conformance harness integration — `runStorageConformance` (from `@suaveplan/storage`) executed against a `StorageMux` configured with N `MemoryStorage` backends and each policy.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Provider-specific backends.** The mux composes `StorageLike` values handed in by the caller; it does not instantiate CDN clients or know provider authentication.
|
||||
- **Cross-backend replication / consistency.** Writes under `shield` are best-effort fan-out with `WRITE_FANOUT_PARTIAL` surfacing partial failures — there is no two-phase commit, no quorum, no vector clocks. Stronger consistency is `@suaveplan/vault-sync` territory.
|
||||
- **Geo-aware routing.** Backend selection is by ordered list + policy. Geographic affinity belongs in a higher-level router that constructs the backend list per-request.
|
||||
- **Adaptive policy switching.** The policy is fixed at construction; the mux never silently switches `failover` → `round-robin` based on load.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis-original. No upstream in `../suaveplan/`. The design draws from:
|
||||
|
||||
- `openspec/archive/2026-05-16-add-storage-cdn/proposal.md` — the explicit deferral naming `storage-mux`.
|
||||
- Industry conventions: HAProxy / Envoy passive+active health checks; HashiCorp Consul service-mesh failover; AWS Route 53 health-check failover routing; NGINX `upstream` `max_fails` / `fail_timeout`. The four policies (`failover`, `round-robin`, `sticky-by-key`, `shield`) are the union of what those tools offer in the storage-IO dimension.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Pluggable `RetryStrategy` via `@suaveplan/retry` | Reimplementing retry in the mux would duplicate `retry`'s state machine and diverge from the rest of the monorepo. Composition keeps the contract uniform. |
|
||||
| Backend isolation (slow primary cannot block fallback) | A timeout-aware race or per-backend abort is required because a hung primary that never rejects starves the failover chain. The design uses `Promise.race` against a clock-driven timeout, not a parallel call — parallel-call shielding (`shield` writes) is opt-in. |
|
||||
| `WRITE_FANOUT_PARTIAL` distinct from `ALL_BACKENDS_DOWN` | `shield` writes intentionally fan out to mirrors; one mirror down does not fail the operation, but the caller deserves a signal. A separate code lets callers choose to surface or swallow it. |
|
||||
| `sticky-by-key` uses a stable hash, not a Map cache | Cache memory grows unbounded under high-cardinality keys. Hashing keeps the policy O(1) memory regardless of key population. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/mux/mux.test.ts` — construction validates non-empty backend list, policy whitelist, weights.
|
||||
- `src/policies/failover/failover.test.ts` — fall-through on transient error; surface non-transient; respect AbortSignal; `ALL_BACKENDS_DOWN` after exhausting list.
|
||||
- `src/policies/round-robin/round-robin.test.ts` — even distribution under N calls; skip down backends.
|
||||
- `src/policies/sticky-by-key/sticky-by-key.test.ts` — same key → same backend across calls; redistribute when backend down.
|
||||
- `src/policies/shield/shield.test.ts` — reads hit primary first then mirrors; writes fan out; `WRITE_FANOUT_PARTIAL` on partial mirror failure.
|
||||
- `src/health/health.test.ts` — passive degrade + exponential cooldown; active polling via injected `Clock`; recovery emits event.
|
||||
- `src/mux/mux.spec-conformance.test.ts` — `runStorageConformance` passes with 3× `MemoryStorage` backends under every policy.
|
||||
- `src/error-codes/error-codes.test.ts` — every `STORAGE_MUX_*` code reachable from a real call site.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-mux` to enumerate. The package is new — no current dependents.
|
||||
|
||||
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-mux` returning the package itself).
|
||||
- Forward-declared consumers: `@suaveplan/storage-plugin-cache` (shield mode), `@suaveplan/drive-plugin-chunks` (sticky-by-key), application code composing `storage-cdn-bunnycdn` + `storage-cdn-rackspace` (failover).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
- `@suaveplan/storage` — consumed via `AbstractStorage` + `StorageLike` + `runStorageConformance`. No augmentation. [x] no action needed.
|
||||
- `@suaveplan/retry` — consumed via `RetryStrategy` + `runWithRetry`. No augmentation. [x] no action needed.
|
||||
- `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types` — standard foundation deps. [x] no action needed.
|
||||
@@ -0,0 +1,256 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-mux (storage-mux)
|
||||
|
||||
**Package**: `@suaveplan/storage-mux`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: StorageMux extends AbstractStorage
|
||||
|
||||
The package SHALL provide a `StorageMux` class extending `AbstractStorage` from `@suaveplan/storage`. The class SHALL accept `StorageMuxOptions` containing an ordered non-empty `backends: BackendEntry[]` and a `policy: MuxPolicy`. The class SHALL implement every method of the `StorageLike` surface (`get`, `set`, `setPartial`, `has`, `delete`, `list`, `stat`, `getStream`, `setStream`) by delegating to a `BackendSelector` chosen from the configured policy.
|
||||
|
||||
#### Scenario: subclass is an instance of AbstractStorage
|
||||
|
||||
- **WHEN** a `StorageMux` is constructed with at least one `BackendEntry`
|
||||
- **THEN** the instance is `instanceof AbstractStorage` AND exposes every `StorageLike` method
|
||||
|
||||
#### Scenario: empty backend list is rejected at construction
|
||||
|
||||
- **WHEN** a caller invokes `new StorageMux({ backends: [], policy: "failover" })`
|
||||
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.NO_BACKENDS_CONFIGURED"`
|
||||
|
||||
**Verified by:** `src/mux/mux.test.ts::"is instance of AbstractStorage and exposes StorageLike surface"` AND `src/mux/mux.test.ts::"rejects empty backend list with NO_BACKENDS_CONFIGURED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MuxPolicy discriminated union with four policies
|
||||
|
||||
The package SHALL declare `MuxPolicy` as the string union `"failover" | "round-robin" | "sticky-by-key" | "shield"`. Each policy SHALL be implemented as a `BackendSelector` strategy class in its own subdirectory under `src/policies/`. The `StorageMuxOptions.policy` field SHALL be validated by a Zod schema that rejects any value outside this whitelist with `STORAGE_MUX.INVALID_POLICY`.
|
||||
|
||||
#### Scenario: unknown policy string is rejected
|
||||
|
||||
- **WHEN** a caller invokes `new StorageMux({ backends, policy: "magic" as MuxPolicy })`
|
||||
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.INVALID_POLICY"`
|
||||
|
||||
**Verified by:** `src/mux/mux.test.ts::"rejects unknown policy with INVALID_POLICY"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: failover policy falls through on transient errors and surfaces semantic errors
|
||||
|
||||
Under the `failover` policy the mux SHALL invoke `backends[0]` first. On a transient classified error (per the error-classification table in `design.md` §4) the mux SHALL advance to `backends[1]`, then `backends[2]`, and so on. On a semantic error (`STORAGE.KEY_NOT_FOUND`, `STORAGE.INVALID_KEY`, 4xx other than 408/429, `CREDENTIAL_EXPIRED`) the mux SHALL surface the error directly without advancing. When every backend has been tried and the last attempt was transient, the mux SHALL throw `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"`, the prior error as `cause`, and `attemptCount` equal to the number of backends tried.
|
||||
|
||||
#### Scenario: transient error advances to next backend
|
||||
|
||||
- **WHEN** `backends[0].get(key)` rejects with a 503 and `backends[1].get(key)` resolves with bytes
|
||||
- **THEN** the mux returns `backends[1]`'s bytes AND emits a `storage-mux.failover` event naming both backends
|
||||
|
||||
#### Scenario: semantic KEY_NOT_FOUND is not retried across backends
|
||||
|
||||
- **WHEN** `backends[0].get(key)` throws `STORAGE.KEY_NOT_FOUND`
|
||||
- **THEN** the mux rethrows `KEY_NOT_FOUND` without invoking `backends[1]`
|
||||
|
||||
#### Scenario: exhausting all backends throws ALL_BACKENDS_DOWN with cause chain
|
||||
|
||||
- **WHEN** every backend rejects with a transient error
|
||||
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"` AND `cause` is the last backend's error AND `attemptCount === backends.length`
|
||||
|
||||
**Verified by:** `src/policies/failover/failover.test.ts::"falls through to next backend on transient 503"` AND `src/policies/failover/failover.test.ts::"surfaces semantic KEY_NOT_FOUND without fall-through"` AND `src/policies/failover/failover.test.ts::"throws ALL_BACKENDS_DOWN after exhausting list"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: round-robin policy distributes across healthy backends
|
||||
|
||||
Under the `round-robin` policy the mux SHALL maintain a monotonic counter and select `healthyBackends[counter mod healthyBackends.length]` for each call. Backends marked down by `BackendHealth` SHALL be skipped. When zero backends are healthy the mux SHALL throw `STORAGE_MUX.ALL_BACKENDS_DOWN`.
|
||||
|
||||
#### Scenario: ten calls across three healthy backends distribute evenly
|
||||
|
||||
- **WHEN** the caller issues 9 `get` calls against a mux with 3 healthy backends
|
||||
- **THEN** each backend receives exactly 3 calls
|
||||
|
||||
#### Scenario: down backend is skipped
|
||||
|
||||
- **WHEN** `backends[1]` is marked down and the caller issues 4 calls
|
||||
- **THEN** `backends[0]` and `backends[2]` each receive 2 calls AND `backends[1]` receives 0
|
||||
|
||||
**Verified by:** `src/policies/round-robin/round-robin.test.ts::"distributes nine calls evenly across three backends"` AND `src/policies/round-robin/round-robin.test.ts::"skips backends marked down"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: sticky-by-key policy is deterministic and skips down backends
|
||||
|
||||
Under the `sticky-by-key` policy the mux SHALL compute `index = fnv1a(utf8(key)) mod healthyBackends.length` and route every operation for that key to the same backend. The hash SHALL be pure-JS FNV-1a (no native or crypto dep). When a backend goes down its keys SHALL be redistributed across the remaining healthy backends via the same formula recomputed against the shrunken set.
|
||||
|
||||
#### Scenario: same key resolves to same backend across 100 calls
|
||||
|
||||
- **WHEN** the caller issues 100 `get("alpha")` calls against a mux with 3 healthy backends
|
||||
- **THEN** exactly one backend receives all 100 calls
|
||||
|
||||
#### Scenario: removed backend triggers redistribution
|
||||
|
||||
- **WHEN** the backend currently serving `"alpha"` is marked down
|
||||
- **THEN** subsequent `get("alpha")` calls land on one of the remaining healthy backends AND the choice is stable until the health set changes again
|
||||
|
||||
**Verified by:** `src/policies/sticky-by-key/sticky-by-key.test.ts::"routes the same key to the same backend across 100 calls"` AND `src/policies/sticky-by-key/sticky-by-key.test.ts::"redistributes keys when a backend goes down"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: shield policy cascades reads and fans out writes
|
||||
|
||||
Under the `shield` policy the mux SHALL treat `backends[0]` as the origin and `backends[1..n]` as mirrors. For read operations (`get`, `has`, `stat`, `getStream`) the mux SHALL invoke `backends[0]` first and on transient error or timeout fall through to mirrors in order. For write operations (`set`, `setPartial`, `delete`, `setStream`) the mux SHALL invoke `backends[0]` AND every mirror via `Promise.allSettled`. If the origin write rejects the mux SHALL throw with the origin error as `cause`. If the origin write succeeds but one or more mirror writes reject the mux SHALL emit `storage-mux.fanout-partial` carrying every failed mirror name AND return successfully (the operation is considered durable once the origin accepts it). The mux SHALL additionally surface a `StorageMuxError` with `code === "STORAGE_MUX.WRITE_FANOUT_PARTIAL"` when the caller opts in via `StorageMuxOptions.throwOnFanoutPartial === true`.
|
||||
|
||||
#### Scenario: read falls through to mirror after origin timeout
|
||||
|
||||
- **WHEN** `backends[0].get(key)` hangs past `backendTimeoutMs` and `backends[1].get(key)` resolves with bytes
|
||||
- **THEN** the mux returns `backends[1]`'s bytes
|
||||
|
||||
#### Scenario: write succeeds when origin accepts and mirror rejects
|
||||
|
||||
- **WHEN** `backends[0].set` resolves and `backends[1].set` rejects with a transient error
|
||||
- **THEN** the mux returns successfully AND emits `storage-mux.fanout-partial` naming `backends[1]`
|
||||
|
||||
#### Scenario: write fails when origin rejects
|
||||
|
||||
- **WHEN** `backends[0].set` rejects with any error
|
||||
- **THEN** the mux throws AND `cause` is the origin error AND mirror outcomes are not consulted
|
||||
|
||||
**Verified by:** `src/policies/shield/shield.test.ts::"reads fall through to mirror after origin timeout"` AND `src/policies/shield/shield.test.ts::"write succeeds when origin accepts and one mirror rejects"` AND `src/policies/shield/shield.test.ts::"write fails when origin rejects regardless of mirror outcomes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: backend isolation — slow primary cannot block fallback
|
||||
|
||||
The mux SHALL wrap every backend call in a `Promise.race` against a `Clock`-driven timeout of `backendTimeoutMs` (default 30000, configurable per-mux). When the race rejects due to timeout the mux SHALL classify the timeout as transient AND proceed to the next backend per policy. The underlying backend promise MAY remain pending; the mux SHALL NOT await it. The mux SHALL link the user `AbortSignal` and the timeout signal via `AbortSignal.any` and pass the combined signal to every backend call so well-behaved backends abort promptly.
|
||||
|
||||
#### Scenario: hung primary does not delay fallback beyond backendTimeoutMs
|
||||
|
||||
- **WHEN** `backends[0].get` returns a never-resolving promise and `backends[1].get` resolves in 10ms
|
||||
- **THEN** the caller receives `backends[1]`'s value in less than `backendTimeoutMs + 50ms` measured against the injected `FakeClock`
|
||||
|
||||
**Verified by:** `src/mux/mux.test.ts::"hung primary does not block fallback past backendTimeoutMs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation surfaces BACKEND_ABORTED
|
||||
|
||||
When the caller's `AbortSignal` aborts during a mux operation, the mux SHALL surface a `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` rather than falling through to subsequent backends. The mux SHALL NOT classify a caller-driven abort as a transient backend failure.
|
||||
|
||||
#### Scenario: caller abort surfaces BACKEND_ABORTED and does not advance
|
||||
|
||||
- **WHEN** the caller passes a pre-aborted signal to `mux.get(key, signal)`
|
||||
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` AND `backends[1].get` is NEVER invoked
|
||||
|
||||
**Verified by:** `src/mux/mux.test.ts::"caller abort surfaces BACKEND_ABORTED without falling through"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BackendHealth — passive degrade with exponential cooldown
|
||||
|
||||
The package SHALL maintain a `BackendHealth` tracker per backend. A backend SHALL transition from `up` to `down` on the first classified-transient error. The cooldown SHALL be `cooldownMs * 2 ** min(consecutiveFailures, 6)` (exponential, capped at 64× base, default `cooldownMs` 1000). A `down` backend SHALL be skipped by every selector. Once `Clock.now() >= cooldownUntil` the next operation SHALL probe the backend; success transitions it back to `up` and resets `consecutiveFailures`.
|
||||
|
||||
#### Scenario: failing backend cools down for 2000ms after second failure
|
||||
|
||||
- **WHEN** a backend rejects twice in succession (default `cooldownMs` 1000)
|
||||
- **THEN** the backend is `down` AND remains skipped until `FakeClock` advances by 2000ms
|
||||
|
||||
#### Scenario: successful probe after cooldown restores backend and emits recovered event
|
||||
|
||||
- **WHEN** `FakeClock` advances past `cooldownUntil` AND the next probe succeeds
|
||||
- **THEN** the backend is `up` AND a `storage-mux.backend.recovered` event is emitted AND `consecutiveFailures` is reset to 0
|
||||
|
||||
**Verified by:** `src/health/health.test.ts::"exponential cooldown after consecutive failures"` AND `src/health/health.test.ts::"successful probe after cooldown restores backend and emits recovered"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Optional active healthchecks via injected Clock
|
||||
|
||||
When `BackendEntry.healthcheck` is provided AND `StorageMuxOptions.healthcheckIntervalMs` is set, the mux SHALL schedule the healthcheck via the injected `Clock.setTimeout` (or `Clock.setInterval`) and invoke it with an `AbortSignal`. A rejecting healthcheck SHALL mark the backend `down` AND emit `storage-mux.backend.down` carrying `STORAGE_MUX.HEALTHCHECK_FAILED` as the cause. The mux SHALL stop scheduling when the mux is disposed.
|
||||
|
||||
#### Scenario: rejecting active healthcheck marks backend down
|
||||
|
||||
- **WHEN** an active healthcheck rejects with a network error
|
||||
- **THEN** the backend transitions to `down` AND a `storage-mux.backend.down` event is emitted AND the failure cause is `STORAGE_MUX.HEALTHCHECK_FAILED`
|
||||
|
||||
**Verified by:** `src/health/health.test.ts::"rejecting active healthcheck marks backend down with HEALTHCHECK_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Retry composition via @suaveplan/retry
|
||||
|
||||
Every per-backend invocation SHALL be wrapped by `runWithRetry(retryStrategy, ...)` from `@suaveplan/retry` using the strategy supplied in `StorageMuxOptions.retry` (defaulting to `exponentialBackoff()` from `@suaveplan/retry/strategies/exponential/exponential`). The mux SHALL NOT reimplement backoff, jitter, or attempt counting; all retry behavior MUST be expressed by composing the retry strategy.
|
||||
|
||||
#### Scenario: backend that fails twice then succeeds is retried in place before fall-through
|
||||
|
||||
- **WHEN** `backends[0].get` rejects twice then resolves on the third attempt under a 3-attempt retry strategy
|
||||
- **THEN** the mux returns `backends[0]`'s value AND `backends[1].get` is NEVER invoked
|
||||
|
||||
**Verified by:** `src/policies/failover/failover.test.ts::"retries in place before falling through to next backend"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: STORAGE_MUX error registry — 8 codes reachable
|
||||
|
||||
The package SHALL declare `STORAGE_MUX_CODES` via `defineCodes("STORAGE_MUX", [...])` with exactly these 8 codes: `ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`. The package SHALL declare `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with optional `backendName`, `attemptIndex`, and `attemptCount` discriminants. Every code SHALL be reachable from a real call site exercised by tests in this package.
|
||||
|
||||
#### Scenario: every declared code is reachable from a real call site
|
||||
|
||||
- **WHEN** the test suite is run
|
||||
- **THEN** every code in `STORAGE_MUX_CODES` has at least one test that exercises the call site that throws or carries it
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every STORAGE_MUX code is reachable from a real call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Observability — EventBusLike events on lifecycle transitions
|
||||
|
||||
The mux SHALL emit events through an injected `EventBusLike` (from `@suaveplan/types`) on the following lifecycle transitions: `storage-mux.backend.down` (passive or active failure), `storage-mux.backend.recovered` (probe success after cooldown), `storage-mux.failover` (failover policy advances to a new backend), `storage-mux.fanout-partial` (shield-policy mirror write rejected while origin succeeded). When no `EventBusLike` is provided the mux SHALL operate normally without emitting (silent fallback).
|
||||
|
||||
#### Scenario: failover emits storage-mux.failover with both backend names
|
||||
|
||||
- **WHEN** the failover policy advances from `backends[0]` to `backends[1]` due to a 503
|
||||
- **THEN** exactly one `storage-mux.failover` event is emitted carrying both backend names AND the underlying error
|
||||
|
||||
**Verified by:** `src/observability/events.test.ts::"failover emits storage-mux.failover with from and to backend names"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Observability — OTel span attributes per backend call
|
||||
|
||||
Every backend call SHALL be wrapped in an OTel span carrying the attributes `mux.backend.name` (string), `mux.attempt.index` (number, 0-based), and `mux.policy` (string). When no tracer is provided (`TracerLike` from `@suaveplan/types`) the mux SHALL operate normally without recording spans.
|
||||
|
||||
#### Scenario: failover attempt records two spans with attempt indices 0 and 1
|
||||
|
||||
- **WHEN** the failover policy invokes `backends[0]` (which rejects) then `backends[1]` (which succeeds)
|
||||
- **THEN** the test tracer records two spans with `mux.attempt.index` equal to `0` and `1` respectively AND matching `mux.backend.name`
|
||||
|
||||
**Verified by:** `src/observability/otel.test.ts::"records one span per backend attempt with attempt index and backend name"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: StorageLike conformance under every policy
|
||||
|
||||
The mux SHALL pass `runStorageConformance` (from `@suaveplan/storage`) when configured with three `MemoryStorage` backends under each of the four policies (`failover`, `round-robin`, `sticky-by-key`, `shield`). The conformance suite SHALL include `setPartial` sparse-extension and overwrite semantics, capability-honest reporting, and AbortSignal propagation.
|
||||
|
||||
#### Scenario: conformance suite passes under failover, round-robin, sticky-by-key, and shield
|
||||
|
||||
- **WHEN** `runStorageConformance(mux)` is invoked four times — once per policy — against the same three-MemoryStorage backend set
|
||||
- **THEN** every conformance assertion passes for every policy
|
||||
|
||||
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under failover"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under round-robin"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under sticky-by-key"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under shield"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tier purity — universal, no Node or DOM imports
|
||||
|
||||
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. Hashing SHALL be pure-JS (FNV-1a). Timeouts SHALL use the injected `Clock`. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
|
||||
|
||||
#### Scenario: tier-purity grep finds no Node or DOM imports
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-mux/src/` is run
|
||||
- **THEN** the output is empty
|
||||
|
||||
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`
|
||||
@@ -0,0 +1,92 @@
|
||||
# Tasks — storage-mux
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/specs/storage/spec.md` and confirm `AbstractStorage` surface
|
||||
- [ ] 1.2 Read `openspec/AGENTS.md` §0c, §13, §14
|
||||
- [ ] 1.3 Read archived `openspec/archive/2026-05-16-add-storage-cdn/` for the deferral and family conventions
|
||||
- [ ] 1.4 Read `packages/flow/retry/src/retry/retry.ts` to confirm `runWithRetry` + `RetryStrategy` surface
|
||||
- [ ] 1.5 Draft `proposal.md`, `design.md`, `specs/storage-mux/spec.md`, `tasks.md`
|
||||
- [ ] 1.6 Numbered Requirement/Scenario checklist per §0c step 6
|
||||
|
||||
## 2. Scaffolding
|
||||
|
||||
- [ ] 2.1 Create `packages/storage/storage-mux/` mirroring `packages/storage/storage-cdn/` conventions
|
||||
- [ ] 2.2 `package.json` v0.0.0, `"sideEffects": false`, subpath exports per feature
|
||||
- [ ] 2.3 Deps: `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/retry`, `@suaveplan/storage`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib`
|
||||
- [ ] 2.4 DevDeps: `@suaveplan/testing`, `@vitest/coverage-istanbul`, `fast-check`, `mitata`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`
|
||||
- [ ] 2.5 `tsconfig`, `vite.config.ts`, `vitest.config.ts` via `createTestConfig`
|
||||
- [ ] 2.6 `README.md` root with Modules section linking every `.md`
|
||||
|
||||
## 3. Error registry
|
||||
|
||||
- [ ] 3.1 `src/error-codes.ts` — `defineCodes("STORAGE_MUX", [...])` with 8 codes (`ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`)
|
||||
- [ ] 3.2 `src/error-codes.ts` — `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with `backendName`, `attemptIndex`, `attemptCount` discriminants
|
||||
|
||||
## 4. Types & schemas
|
||||
|
||||
- [ ] 4.1 `src/types/backend-entry.types.ts` — `BackendEntry` interface (`name`, `storage`, `weight?`, `healthcheck?`)
|
||||
- [ ] 4.2 `src/types/mux-options.types.ts` — `MuxPolicy` discriminated string union, `StorageMuxOptions`
|
||||
- [ ] 4.3 `src/types/mux-options.schemas.ts` — Zod schemas validating non-empty backends, policy whitelist, weight ≥ 0
|
||||
|
||||
## 5. Selectors / policies
|
||||
|
||||
- [ ] 5.1 `src/policies/selector.types.ts` — `BackendSelector` interface (`select(op, key?, healthy): BackendEntry`)
|
||||
- [ ] 5.2 `src/policies/classify.ts` + `.md` + `.test.ts` — pure error classification per design §4 table
|
||||
- [ ] 5.3 `src/policies/failover/failover.ts` + `.md` + `.test.ts` — fall-through + retry composition
|
||||
- [ ] 5.4 `src/policies/round-robin/round-robin.ts` + `.md` + `.test.ts` — atomic counter, skip down
|
||||
- [ ] 5.5 `src/policies/sticky-by-key/sticky-by-key.ts` + `.md` + `.test.ts` — FNV-1a hash, deterministic
|
||||
- [ ] 5.6 `src/policies/shield/shield.ts` + `.md` + `.test.ts` — read cascade, write fan-out, partial signal
|
||||
|
||||
## 6. Health
|
||||
|
||||
- [ ] 6.1 `src/health/health.ts` + `.md` + `.test.ts` — `BackendHealth` tracker, exponential cooldown, recovery probe, active healthcheck via injected `Clock`
|
||||
|
||||
## 7. Mux core
|
||||
|
||||
- [ ] 7.1 `src/mux/mux.ts` + `.md` — `StorageMux extends AbstractStorage`, per-op routing through `BackendSelector` + `runWithRetry`, per-backend timeout race, AbortSignal linking
|
||||
- [ ] 7.2 `src/mux/mux.test.ts` — construction validation, policy dispatch, abort surfacing
|
||||
- [ ] 7.3 `src/mux/mux.spec-conformance.test.ts` — `runStorageConformance` × 4 policies × 3 `MemoryStorage` backends
|
||||
|
||||
## 8. Observability
|
||||
|
||||
- [ ] 8.1 `src/observability/events.ts` + `.md` + `.test.ts` — event topic constants (`storage-mux.backend.down`, `.recovered`, `.failover`, `.fanout-partial`), `emit*` helpers consuming `EventBusLike`
|
||||
- [ ] 8.2 `src/observability/otel.ts` + `.md` + `.test.ts` — span attribute helpers (`mux.backend.name`, `mux.attempt.index`, `mux.policy`)
|
||||
|
||||
## 9. Barrel + exports
|
||||
|
||||
- [ ] 9.1 `src/index.ts` — explicit named re-exports per §6a (NO `export *`)
|
||||
- [ ] 9.2 `package.json` subpath exports match every feature directory
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- [ ] 10.1 `src/error-codes/error-codes.test.ts` — every `STORAGE_MUX_*` code reachable from a real call site
|
||||
- [ ] 10.2 All test imports via `@suaveplan/testing/runner` (§7)
|
||||
- [ ] 10.3 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async health)
|
||||
- [ ] 10.4 Backend isolation test: hung-promise primary does NOT block fallback within `backendTimeoutMs`
|
||||
|
||||
## 11. Documentation
|
||||
|
||||
- [ ] 11.1 Sibling `.md` for every implementation `.ts` (≥ 200 words, all required sections)
|
||||
- [ ] 11.2 `README.md` documents each policy with a usage example
|
||||
|
||||
## 12. Gates
|
||||
|
||||
- [ ] 12.1 `bun run check`
|
||||
- [ ] 12.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-mux`
|
||||
- [ ] 12.3 `bunx turbo test:coverage --filter=@suaveplan/storage-mux` (100/100/100/100)
|
||||
- [ ] 12.4 `bun scripts/lint/check-test-runner-imports.ts`
|
||||
- [ ] 12.5 Tier-purity grep `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-mux/src/` returns empty
|
||||
- [ ] 12.6 Banned-pattern scan returns empty
|
||||
- [ ] 12.7 `bun scripts/openspec-conformance.ts @suaveplan/storage-mux`
|
||||
|
||||
## 13. Manifest & archival
|
||||
|
||||
- [ ] 13.1 `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
- [ ] 13.2 `bun scripts/generate-missing.ts`
|
||||
- [ ] 13.3 Update `STATUS.md` and `SUMMARY.md`
|
||||
- [ ] 13.4 `bunx markdownlint-cli2 --fix`
|
||||
- [ ] 13.5 Fold `specs/storage-mux/spec.md` → `openspec/specs/storage-mux/spec.md`
|
||||
- [ ] 13.6 Move change folder → `openspec/archive/<yyyy-mm-dd>-add-storage-mux/`
|
||||
@@ -0,0 +1,249 @@
|
||||
# Design: add-storage-s3-multipart
|
||||
|
||||
Non-normative. The normative contract is `./specs/storage-s3/spec.md`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Large writes (> 16 MiB by default, configurable) flow through S3 multipart
|
||||
without any caller code change.
|
||||
2. Streaming input (`ReadableStream<Uint8Array>`) is chunked into parts on the
|
||||
fly — no whole-stream buffering.
|
||||
3. Parts upload in bounded parallel for WAN throughput; failures cancel
|
||||
in-flight workers and clean up the open `UploadId`.
|
||||
4. Error provenance is preserved at stage granularity (init / part / complete
|
||||
/ abort) so callers and dashboards can attribute failures.
|
||||
5. Zero changes to `StorageLike`; multipart is invisible to consumers who don't
|
||||
need to know.
|
||||
|
||||
## Trigger threshold
|
||||
|
||||
S3 enforces a minimum part size of **5 MiB** (except the final part). Maximum
|
||||
parts per upload is **10 000**. Maximum object size is **5 TiB**.
|
||||
|
||||
The default trigger is **16 MiB**. Rationale:
|
||||
|
||||
- Below 16 MiB a single PUT is faster (one round trip, no MPU bookkeeping).
|
||||
- Above 16 MiB the latency of the three MPU control calls amortises against
|
||||
the parallelism win.
|
||||
- 16 MiB / 5 MiB part = 4 parts at the floor, which matches the default
|
||||
concurrency of 4 — every part can start in parallel.
|
||||
|
||||
Caller override via `options.multipart.threshold`. Minimum legal value is
|
||||
5 MiB (anything smaller would force the first part below the AWS floor).
|
||||
|
||||
## API surface
|
||||
|
||||
Public surface is unchanged. The new opt-in shape is added to `S3Options` (and
|
||||
to a per-call `StorageOptions`-shaped overlay for `set` only, so single-call
|
||||
overrides are possible without reconfiguring the adapter):
|
||||
|
||||
```ts
|
||||
export interface MultipartTuning {
|
||||
/** Bytes; default 16 MiB; minimum 5 MiB. */
|
||||
readonly threshold?: number;
|
||||
/** Bytes; default 8 MiB; minimum 5 MiB; maximum 5 GiB per AWS. */
|
||||
readonly partSize?: number;
|
||||
/** Concurrent part workers; default 4; minimum 1; maximum 32. */
|
||||
readonly concurrency?: number;
|
||||
}
|
||||
|
||||
// Added to S3Options
|
||||
readonly multipart?: MultipartTuning;
|
||||
```
|
||||
|
||||
`setMultipart` keeps its existing signature (it is already public) and becomes
|
||||
a thin wrapper over the new engine.
|
||||
|
||||
## Internal engine
|
||||
|
||||
One private method, `#runMultipart`, owns the entire CreateMPU → parallel-parts
|
||||
→ CompleteMPU lifecycle. Both `set` (when over threshold) and `setMultipart`
|
||||
delegate to it.
|
||||
|
||||
### Pseudo-shape
|
||||
|
||||
```ts
|
||||
type PartInput = { number: number; body: Uint8Array };
|
||||
|
||||
async #runMultipart(
|
||||
fullKey: string,
|
||||
parts: AsyncIterable<PartInput>,
|
||||
tuning: Required<MultipartTuning>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> {
|
||||
const uploadId = await this.#initMultipart(fullKey, signal);
|
||||
const completed: { ETag: string; PartNumber: number }[] = [];
|
||||
try {
|
||||
await this.#runPartWorkers(
|
||||
fullKey, uploadId, parts, tuning.concurrency, completed, signal,
|
||||
);
|
||||
completed.sort((a, b) => a.PartNumber - b.PartNumber);
|
||||
await this.#completeMultipart(fullKey, uploadId, completed, signal);
|
||||
} catch (err) {
|
||||
await this.#abortMultipart(fullKey, uploadId).catch(/* logged */);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Part chunker
|
||||
|
||||
A generator `#chunkStream(stream, partSize)` reads from
|
||||
`ReadableStream<Uint8Array>` and yields `PartInput` records exactly `partSize`
|
||||
bytes long, plus a final shorter part. Memory ceiling is `partSize` + the
|
||||
single in-flight buffer from `reader.read()`.
|
||||
|
||||
For the byte-array `set` path, the chunker is bypassed: parts are sliced
|
||||
directly from the input `Uint8Array` and yielded synchronously. This avoids
|
||||
an unnecessary `ReadableStream` allocation for the common case.
|
||||
|
||||
### Worker pool
|
||||
|
||||
`#runPartWorkers` keeps `tuning.concurrency` pending `UploadPart` promises in
|
||||
flight. As one resolves, its `{ ETag, PartNumber }` is pushed to `completed`
|
||||
and the next part is drawn from the iterator. The pool uses a structured
|
||||
`Promise.race`-based scheduler — when any worker rejects, the pool aborts
|
||||
remaining workers via a shared `AbortController` linked to the caller's
|
||||
`signal`, then re-throws the first error.
|
||||
|
||||
### Abort propagation
|
||||
|
||||
The engine creates an internal `AbortController` (`linked`). Its signal is
|
||||
passed to every `S3Client.send` call. `linked` is aborted when either:
|
||||
|
||||
- the caller-supplied `signal` fires (via `signal.addEventListener("abort")`),
|
||||
- any part worker rejects.
|
||||
|
||||
The `AbortMultipartUploadCommand` is sent with a **fresh** signal (no abort
|
||||
inheritance) so cleanup still runs after the caller aborts. Cleanup failures
|
||||
are caught, wrapped in `MULTIPART_ABORT_FAILED`, logged at warn level, and
|
||||
swallowed — re-throwing them would mask the original error.
|
||||
|
||||
### Error mapping
|
||||
|
||||
| Stage | Code |
|
||||
|---|---|
|
||||
| `CreateMultipartUpload` failure or missing `UploadId` | `MULTIPART_INIT_FAILED` |
|
||||
| `UploadPart` failure (after retry) | `MULTIPART_PART_FAILED` (with `partNumber` in context) |
|
||||
| `CompleteMultipartUpload` failure | `MULTIPART_COMPLETE_FAILED` |
|
||||
| `AbortMultipartUpload` failure during cleanup | `MULTIPART_ABORT_FAILED` (logged, not thrown) |
|
||||
|
||||
All four are registered via `defineCodes("S3", [...] as const)` alongside the
|
||||
existing entries. The legacy `MULTIPART_FAILED` code remains in the registry
|
||||
(backwards-compat alias; no new call site raises it) so any consumer that
|
||||
asserts on it does not see an `unknown code` lookup failure.
|
||||
|
||||
## Resumability — out of scope
|
||||
|
||||
Resumable uploads require persisting `{ key, uploadId, completedParts[] }`
|
||||
across process restarts. That introduces a storage dependency, a recovery
|
||||
protocol, and a garbage-collection contract for orphaned `UploadId`s — none
|
||||
of which fit inside `StorageLike`. Callers needing resume should layer a
|
||||
dedicated coordinator over `S3.setMultipart`, using `S3Client` directly for
|
||||
`ListPartsCommand` discovery.
|
||||
|
||||
## Streaming input
|
||||
|
||||
`setStream(key, stream, opts?)` is the new internal helper, exposed via the
|
||||
existing `setMultipart` public method (which already accepts a
|
||||
`ReadableStream`). The byte-array `set` path constructs a synchronous part
|
||||
iterator from the input buffer instead of routing through `ReadableStream` —
|
||||
two ingress paths, one engine.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Per CLAUDE.md rule 2 (no mocks): the multipart engine is tested end-to-end
|
||||
against a real S3-compatible service via testcontainers. The genesis monorepo
|
||||
already has `@suaveplan/testing-integration-garage-s3`
|
||||
(`packages/testing/testing-integration-garage-s3/src/fixture/`) which spins
|
||||
up Garage (S3-compatible) in a container with credentials wired into the
|
||||
fixture's `env-bridge`. This change extends — does NOT replace — that fixture.
|
||||
|
||||
Why Garage over MinIO: Garage is already present in the monorepo fixture set,
|
||||
fully supports S3 multipart (CreateMPU/UploadPart/CompleteMPU/AbortMPU), and
|
||||
runs faster in CI containers than MinIO for the multi-part shapes this test
|
||||
plan exercises.
|
||||
|
||||
If a behaviour is **only** triggerable via a specific MinIO quirk (e.g. SlowDown
|
||||
throttling pattern) it will be added as a second fixture; for the requirements
|
||||
in this change, Garage covers every observable shape.
|
||||
|
||||
### Test plan (in `tests/integration/`)
|
||||
|
||||
1. `set` below threshold → single `PutObject`, no MPU calls (assert via
|
||||
`ListMultipartUploadsCommand` returning empty list mid-test).
|
||||
2. `set` above threshold with default tuning → parts visible mid-flight via
|
||||
`ListPartsCommand`, completes, GET round-trips byte-for-byte.
|
||||
3. `setStream` over threshold with caller-supplied 5 MiB part size → exact
|
||||
part count = `ceil(total / partSize)`.
|
||||
4. Concurrency cap honoured — inject a slow part by interposing a custom
|
||||
`S3ClientFactory` that wraps `UploadPart` with a measured delay; assert
|
||||
the in-flight count never exceeds `concurrency`.
|
||||
5. Mid-upload abort via `AbortSignal` → throws, `ListMultipartUploadsCommand`
|
||||
shows zero open uploads for the key (i.e. cleanup ran).
|
||||
6. Part failure → injected via a wrapping `S3ClientFactory` that fails part
|
||||
3 deterministically; final error is `MULTIPART_PART_FAILED` with
|
||||
`context.partNumber === 3`; cleanup ran.
|
||||
7. Cleanup failure path — `AbortMultipartUpload` injected to fail; original
|
||||
error still surfaces, `MULTIPART_ABORT_FAILED` appears in the logger sink.
|
||||
8. `setPartial` with `offset === 0` and a > threshold payload → uses the
|
||||
multipart engine; `capabilities.partialWrites` stays `false`.
|
||||
9. `setPartial` with non-zero offset → still uses `defaultSetPartial`
|
||||
(read-modify-write) regardless of size.
|
||||
10. Part-size validation: `partSize < 5 MiB` → constructor throws
|
||||
`S3StorageError(MULTIPART_INIT_FAILED, ...)` at config time, not at
|
||||
upload time.
|
||||
|
||||
`vitest.integration.config.ts` already exists. Tests live under
|
||||
`tests/integration/multipart.test.ts` so the unit suite (`src/**/*.test.ts`)
|
||||
stays fast and the integration sweep is opt-in via the existing turbo task.
|
||||
|
||||
### Coverage
|
||||
|
||||
100/100/100/100 on the new module per project gate, including:
|
||||
|
||||
- the `linked` abort listener removal on success,
|
||||
- the cleanup-after-failure branch,
|
||||
- the cleanup-failure swallow branch,
|
||||
- the `partSize < 5 MiB` validation guard.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/storage/storage-s3/src/
|
||||
├── s3/
|
||||
│ ├── s3.ts # set() routes to engine over threshold
|
||||
│ ├── s3.md # updated: threshold + tuning + cap honesty
|
||||
│ ├── s3.test.ts
|
||||
│ └── s3-branches.test.ts
|
||||
├── multipart/ # NEW
|
||||
│ ├── multipart-engine.ts # #runMultipart, init/complete/abort helpers
|
||||
│ ├── multipart-engine.md
|
||||
│ ├── multipart-engine.test.ts
|
||||
│ ├── part-chunker.ts # ReadableStream → AsyncIterable<PartInput>
|
||||
│ ├── part-chunker.md
|
||||
│ ├── part-chunker.test.ts
|
||||
│ ├── worker-pool.ts # bounded-concurrency scheduler with abort
|
||||
│ ├── worker-pool.md
|
||||
│ ├── worker-pool.test.ts
|
||||
│ └── index.ts # explicit named re-exports (rule 10/6a)
|
||||
├── constants/
|
||||
│ └── index.ts # DEFAULT_MULTIPART_THRESHOLD, _CONCURRENCY
|
||||
├── error-codes.ts # +4 codes; MULTIPART_FAILED retained
|
||||
└── types/
|
||||
└── s3.types.ts # MultipartTuning interface
|
||||
|
||||
packages/storage/storage-s3/tests/integration/
|
||||
└── multipart.test.ts # uses testing-integration-garage-s3
|
||||
```
|
||||
|
||||
## Decisions & trade-offs
|
||||
|
||||
| Decision | Alternative | Why |
|
||||
|---|---|---|
|
||||
| Auto-promote inside `set` | Force callers to pick `setMultipart` | Public surface = `StorageLike`; large-write performance shouldn't depend on knowing about S3 specifics. |
|
||||
| Default threshold 16 MiB | 100 MiB (AWS guideline) | At 16 MiB four parallel parts amortise control overhead; lower threshold is safer for chunked-drive workloads where many medium objects benefit. |
|
||||
| `setPartial` whole-object multipart | Always r-m-w | Random-access patches genuinely require r-m-w (S3 has no byte-range PUT); whole-object writes via `setPartial` deserve the fast path. |
|
||||
| `capabilities.partialWrites = false` | Flip to `true` | S3 multipart is whole-object only — flipping the flag would lie to capability-aware consumers (e.g. `drive-plugin-chunks` sparse-file layout). |
|
||||
| Bounded `Promise.race` scheduler | `p-limit` / `p-queue` external dep | One small file, zero new catalog entries, abort handling tightly coupled to the engine. |
|
||||
| Garage testcontainer | MinIO / LocalStack / mocked SDK | Real-service per rule 2; Garage already in `testing-integration-garage-s3`. |
|
||||
@@ -0,0 +1,177 @@
|
||||
# OpenSpec Change: add-storage-s3-multipart
|
||||
|
||||
- **Change ID**: add-storage-s3-multipart
|
||||
- **Package**: `@suaveplan/storage-s3` (modify)
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-17
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/storage-s3/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`S3.set` issues a single `PutObjectCommand` for every payload, which AWS hard-caps
|
||||
at 5 GiB and which leaves WAN throughput on the table for any object above
|
||||
~16 MiB; `S3.setPartial` falls back to a read-modify-write that pulls the entire
|
||||
object into process memory for every write. The
|
||||
`storage-set-partial` change (archived 2026-04-28) explicitly scoped out the
|
||||
native multipart implementation, leaving `storage-s3` as the only adapter
|
||||
without a real large-write path. `S3.setMultipart` exists but is a separate
|
||||
public method with serial parts and a single coarse error code, so neither
|
||||
`set` nor `setPartial` benefit from it. This change closes that gap so
|
||||
large writes and chunk-driven sparse-file workloads (e.g.
|
||||
`@suaveplan/drive-plugin-chunks`) run on the same fast multipart path as
|
||||
hand-written `setMultipart` callers, without changing the `StorageLike` surface.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `S3.set` auto-promotes to multipart when `value.byteLength` exceeds a
|
||||
configurable threshold (default 16 MiB); below it, behaviour is unchanged.
|
||||
- `S3.setPartial` delegates to the multipart engine when `offset === 0` and
|
||||
the write exceeds the threshold; non-zero-offset patches continue to use
|
||||
the read-modify-write fallback.
|
||||
- New internal engine (`multipart/multipart-engine.ts`, `part-chunker.ts`,
|
||||
`worker-pool.ts`) handles `CreateMultipartUpload` → bounded-parallel
|
||||
`UploadPart` (default concurrency 4) → `CompleteMultipartUpload`, with
|
||||
full abort propagation and guaranteed `AbortMultipartUpload` cleanup.
|
||||
- `S3.setMultipart` becomes a thin wrapper over the engine; its public
|
||||
signature is preserved.
|
||||
- `S3_CODES` gains `MULTIPART_INIT_FAILED`, `MULTIPART_PART_FAILED`,
|
||||
`MULTIPART_COMPLETE_FAILED`, `MULTIPART_ABORT_FAILED`; legacy
|
||||
`MULTIPART_FAILED` remains registered as a back-compat alias.
|
||||
- New subpath exports: `./multipart/multipart-engine`, `./multipart/part-chunker`,
|
||||
`./multipart/worker-pool`.
|
||||
- Integration tests run against the existing
|
||||
`@suaveplan/testing-integration-garage-s3` Garage testcontainer fixture —
|
||||
no mocks.
|
||||
|
||||
## Summary
|
||||
|
||||
Wire native S3 multipart upload into the `set` / `setPartial` happy path so large
|
||||
writes use `CreateMultipartUpload` + parallel `UploadPart` + `CompleteMultipartUpload`
|
||||
instead of either a single 5 GiB-capped `PutObject` or the read-modify-write
|
||||
`defaultSetPartial` fallback. Multipart is an **internal optimization**: the public
|
||||
`StorageLike` surface is unchanged; callers may tune behaviour via an opt-in
|
||||
`{ multipart: { partSize, concurrency, threshold } }` option bag.
|
||||
|
||||
This is the deferral noted in
|
||||
`openspec/archive/2026-04-28-storage-set-partial/proposal.md` ("Out of scope —
|
||||
`storage-s3` multipart-upload native impl — follow-up") and the
|
||||
`storage-s3` row of that change's backend wiring table (`setPartial` →
|
||||
`defaultSetPartial`, `partialWrites: false`).
|
||||
|
||||
## Current state
|
||||
|
||||
- `S3.set` (`packages/storage/storage-s3/src/s3/s3.ts:233`) issues a single
|
||||
`PutObjectCommand` regardless of payload size. AWS rejects single PUTs > 5 GiB
|
||||
and recommends multipart for objects > ~100 MiB for throughput.
|
||||
- `S3.setPartial` (`s3/s3.ts:480`) delegates straight to
|
||||
`AbstractStorage#defaultSetPartial`, which reads the whole object, mutates a
|
||||
`Uint8Array` in process memory, then re-PUTs it. O(N) memory and bandwidth per
|
||||
call, unusable for sparse-chunk drive layouts on real-world object sizes.
|
||||
- `S3.setMultipart` (`s3/s3.ts:602`) already implements a correct CreateMPU +
|
||||
serial UploadPart + CompleteMPU loop, but: (a) it is a separate public method
|
||||
callers must opt into by name, so `set` / `setPartial` never benefit from it;
|
||||
(b) part uploads are strictly serial, leaving WAN throughput on the table;
|
||||
(c) only a single `MULTIPART_FAILED` code exists, hiding which stage failed.
|
||||
- `S3.capabilities.partialWrites` is `false`. Once multipart is wired through
|
||||
`setPartial` for whole-object writes, the flag stays `false` (S3 multipart
|
||||
is **not** random-access — it cannot patch a byte range of an existing object)
|
||||
but consumers gain a documented large-object path via the same method.
|
||||
|
||||
## What changes
|
||||
|
||||
1. **Auto-promote `set` to multipart above a threshold.** When
|
||||
`value.byteLength > options.multipart?.threshold ?? DEFAULT_MULTIPART_THRESHOLD`
|
||||
(default 16 MiB), `S3.set` performs CreateMultipartUpload → parallel
|
||||
UploadPart → CompleteMultipartUpload internally instead of `PutObject`. Below
|
||||
the threshold, behaviour is unchanged.
|
||||
2. **Streaming input path.** Add an internal helper `setStream(key, stream, opts)`
|
||||
that chunks a `ReadableStream<Uint8Array>` into parts on the fly without
|
||||
buffering the whole stream. `setMultipart` becomes a thin wrapper that
|
||||
delegates to this helper. The byte-array `set` path uses the same engine via
|
||||
a `ReadableStream.from`-style adapter so there is one canonical implementation.
|
||||
3. **Bounded parallel parts.** Parts are uploaded with a worker-pool of
|
||||
`options.multipart?.concurrency ?? DEFAULT_MULTIPART_CONCURRENCY` (default 4)
|
||||
concurrent `UploadPart` calls. Order is preserved in the final
|
||||
`MultipartUpload.Parts` list by part number.
|
||||
4. **Abort + cleanup.** Any failure or `AbortSignal` firing during parts MUST
|
||||
propagate to in-flight part workers (cancelling their underlying
|
||||
`S3Client.send` via `abortSignal`) and MUST issue exactly one
|
||||
`AbortMultipartUploadCommand` for the open `UploadId`. Cleanup is best-effort
|
||||
(logged, not re-thrown) but is mandatory — leaked MPUs accrue storage cost.
|
||||
5. **Finer error codes.** `MULTIPART_FAILED` is split via `defineCodes` into
|
||||
`MULTIPART_INIT_FAILED`, `MULTIPART_PART_FAILED`, `MULTIPART_COMPLETE_FAILED`,
|
||||
`MULTIPART_ABORT_FAILED`. `MULTIPART_FAILED` is retained as a
|
||||
backwards-compatibility alias on the registry (raised only by call sites
|
||||
that pre-date this change and were not migrated; the four new codes cover
|
||||
every multipart call site post-change).
|
||||
6. **`setPartial` for whole-object writes.** When `offset === 0` and the call
|
||||
semantically writes the entire object (caller intent expressed via
|
||||
`bytes.byteLength` being the whole object), `setPartial` delegates to the
|
||||
same multipart engine. Random-access patches (non-zero offset) continue to
|
||||
use `defaultSetPartial`'s read-modify-write — multipart cannot patch a byte
|
||||
range and `capabilities.partialWrites` remains `false` to advertise this
|
||||
honestly.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Resumable uploads** — persisting `UploadId` + completed-part list across
|
||||
process restarts. Callers needing resume should use a dedicated surface
|
||||
(future `drive-plugin-resume` or equivalent); this change keeps the
|
||||
`StorageLike` contract simple.
|
||||
- **Random-access patching via multipart.** S3 multipart cannot replace an
|
||||
arbitrary byte range of an existing object. `capabilities.partialWrites`
|
||||
stays `false`.
|
||||
- **Server-side encryption / SSE-KMS option passthrough.** Tracked separately.
|
||||
- **Copy-part (`UploadPartCopy`) for large server-side copies.** Tracked
|
||||
separately under `S3.copy` enhancements.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-s3` to enumerate. The
|
||||
change is additive on the public surface (`set` / `setPartial` signatures
|
||||
unchanged; new options are opt-in; new error codes are siblings of an existing
|
||||
one), so the blast radius is small:
|
||||
|
||||
- `@suaveplan/storage` — [ ] tests run / [ ] docs reviewed / [x] no action needed
|
||||
(reason: `storage-s3` consumes `AbstractStorage` from `storage` and adds new
|
||||
behaviour internally; no `storage` surface changes.)
|
||||
- `@suaveplan/drive-plugin-chunks` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(reason: declared consumer of `setPartial` via `DriveStorage`; benefits
|
||||
directly from the large-write path. Verify conformance suite still passes.)
|
||||
- `@suaveplan/drive-plugin-cache` — [ ] tests run / [ ] docs reviewed / [x] no action needed
|
||||
(reason: cache layer round-trips byte ranges, never crosses the multipart
|
||||
threshold in practice; surface 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 it sits under `drive-*`
|
||||
chunk plumbing — the dependent sweep (§13 step 10,
|
||||
`bunx turbo test --filter=...@suaveplan/storage-s3`) MUST be run before archive.
|
||||
|
||||
## Migration
|
||||
|
||||
No public API changes; existing callers see no behaviour difference below the
|
||||
multipart threshold. Callers who explicitly assert on
|
||||
`S3StorageError.code === "S3.MULTIPART_FAILED"` should widen the check to the
|
||||
new four-code set:
|
||||
|
||||
```ts
|
||||
import { S3_CODES } from "@suaveplan/storage-s3";
|
||||
const multipartCodes = new Set<string>([
|
||||
S3_CODES.MULTIPART_INIT_FAILED,
|
||||
S3_CODES.MULTIPART_PART_FAILED,
|
||||
S3_CODES.MULTIPART_COMPLETE_FAILED,
|
||||
S3_CODES.MULTIPART_ABORT_FAILED,
|
||||
S3_CODES.MULTIPART_FAILED, // legacy alias, still emitted by no call site post-change
|
||||
]);
|
||||
if (err instanceof S3StorageError && multipartCodes.has(err.code)) { … }
|
||||
```
|
||||
|
||||
Search command for consumers:
|
||||
|
||||
```bash
|
||||
grep -rln "MULTIPART_FAILED" packages/ | grep -v "/dist/\|/coverage/\|/storage-s3/"
|
||||
```
|
||||
@@ -0,0 +1,235 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: storage-s3 (add-storage-s3-multipart)
|
||||
|
||||
**Package**: `@suaveplan/storage-s3`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
> Test paths name files this change will create; they do not exist yet.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Auto-promote `set` to multipart above threshold
|
||||
|
||||
`S3.set(key, value, options?, signal?)` MUST internally upload `value` via S3
|
||||
multipart (`CreateMultipartUpload` → parallel `UploadPart` →
|
||||
`CompleteMultipartUpload`) when `value.byteLength` exceeds the configured
|
||||
threshold (`S3Options.multipart.threshold`, default 16 MiB). At or below the
|
||||
threshold, behaviour MUST remain a single `PutObjectCommand`. The public
|
||||
signature, return type, and observable success semantics MUST 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 input is chunked without whole-stream buffering
|
||||
|
||||
`S3.setMultipart(key, stream, opts?)` MUST consume a
|
||||
`ReadableStream<Uint8Array>` and emit `UploadPart` requests of exactly
|
||||
`opts.partSize ?? S3Options.multipart.partSize ?? DEFAULT_PART_SIZE` bytes
|
||||
each (except the final part, which MAY be shorter). At no point MAY the
|
||||
implementation buffer more than two parts' worth of bytes in memory
|
||||
(in-flight read + accumulating chunker buffer).
|
||||
|
||||
#### Scenario: stream of N bytes produces ceil(N / partSize) parts
|
||||
|
||||
- **WHEN** a caller invokes `s3.setMultipart("k", stream)` where `stream`
|
||||
yields 25 MiB total and `partSize` is 5 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/multipart.test.ts::"stream over threshold produces expected part count"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parts upload with bounded parallelism
|
||||
|
||||
The multipart engine MUST upload parts with at most
|
||||
`S3Options.multipart.concurrency ?? DEFAULT_MULTIPART_CONCURRENCY` (default 4)
|
||||
`UploadPartCommand` calls in flight simultaneously. Concurrency MUST be at
|
||||
least 1 and at most 32; values outside this range MUST be clamped or rejected
|
||||
at construction time. The final `MultipartUpload.Parts` list passed to
|
||||
`CompleteMultipartUploadCommand` MUST be sorted ascending by `PartNumber`
|
||||
regardless of completion order.
|
||||
|
||||
#### Scenario: concurrency cap is honoured
|
||||
|
||||
- **WHEN** a caller invokes `setMultipart` with 12 parts and concurrency 4,
|
||||
using an injected `S3ClientFactory` that records the count of pending
|
||||
`UploadPart` promises 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 cancels in-flight parts
|
||||
|
||||
The multipart engine MUST react to a caller-supplied `AbortSignal` firing
|
||||
mid-upload by cancelling every in-flight `UploadPartCommand` (via a linked
|
||||
abort signal passed to `S3Client.send`), MUST stop drawing further parts
|
||||
from the input iterator, and MUST issue exactly one
|
||||
`AbortMultipartUploadCommand` for the open `UploadId` before re-throwing.
|
||||
|
||||
#### Scenario: caller aborts mid-upload
|
||||
|
||||
- **WHEN** a caller starts `setMultipart` 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 error codes
|
||||
|
||||
Multipart failures MUST raise `S3StorageError` with a code matching the failed
|
||||
stage: `S3.MULTIPART_INIT_FAILED` when `CreateMultipartUpload` fails or
|
||||
returns no `UploadId`; `S3.MULTIPART_PART_FAILED` when an `UploadPart` call
|
||||
fails after retries, with `partNumber` populated in the error `context`;
|
||||
`S3.MULTIPART_COMPLETE_FAILED` when `CompleteMultipartUpload` fails;
|
||||
`S3.MULTIPART_ABORT_FAILED` when the cleanup `AbortMultipartUpload` itself
|
||||
fails (this code is logged at warn level and MUST NOT replace the original
|
||||
thrown error). The legacy code `S3.MULTIPART_FAILED` MUST remain registered
|
||||
via `defineCodes` as a backwards-compatibility alias but MUST 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 `setMultipart` on a stream
|
||||
with at least three parts
|
||||
- **THEN** the call rejects with `S3StorageError` whose `code` is
|
||||
`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
|
||||
`S3.MULTIPART_PART_FAILED`, and a logger record at warn level carries
|
||||
the `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 the multipart engine
|
||||
|
||||
`S3.setPartial(key, offset, bytes, signal?)` MUST delegate to the multipart
|
||||
engine when `offset === 0` and `bytes.byteLength` exceeds the configured
|
||||
multipart threshold. For all other cases (non-zero offset, or below
|
||||
threshold) it MUST continue to use `AbstractStorage#defaultSetPartial`'s
|
||||
read-modify-write fallback. `S3.capabilities.partialWrites` MUST remain
|
||||
`false` to honestly advertise that random-access byte-range patching is not
|
||||
natively supported by S3 multipart.
|
||||
|
||||
#### Scenario: zero-offset large write takes the 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 write keeps 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`
|
||||
|
||||
#### Scenario: capability flag stays honest
|
||||
|
||||
- **WHEN** a caller reads `s3.capabilities.partialWrites`
|
||||
- **THEN** the value is `false`
|
||||
|
||||
**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"`
|
||||
AND `src/s3/s3.test.ts::"capabilities.partialWrites stays false"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Part-size and tuning validation
|
||||
|
||||
`S3` MUST validate `S3Options.multipart.partSize` against the AWS-mandated
|
||||
minimum of 5 MiB at construction time and throw
|
||||
`S3StorageError(S3.MULTIPART_INIT_FAILED, ...)` with a message naming the
|
||||
offending value. `S3Options.multipart.threshold` MUST be greater than or
|
||||
equal to `MIN_PART_SIZE` (5 MiB). `S3Options.multipart.concurrency` MUST be
|
||||
in the inclusive range `[1, 32]`; out-of-range values MUST throw at
|
||||
construction time. These checks MUST run before any network call is issued.
|
||||
|
||||
#### Scenario: sub-floor part size is rejected at construction
|
||||
|
||||
- **WHEN** a caller constructs `new S3({ ..., multipart: { partSize: 1024 } })`
|
||||
- **THEN** the constructor throws `S3StorageError` whose `code` is
|
||||
`S3.MULTIPART_INIT_FAILED` and whose message names the value `1024`
|
||||
|
||||
**Verified by:** `src/s3/s3.test.ts::"rejects sub-floor partSize at construction time"`
|
||||
AND `src/s3/s3.test.ts::"rejects out-of-range concurrency at construction time"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: New multipart subpath exports
|
||||
|
||||
`@suaveplan/storage-s3/package.json` MUST expose subpath exports for the new
|
||||
multipart modules so consumers can tree-shake: `./multipart/multipart-engine`,
|
||||
`./multipart/part-chunker`, `./multipart/worker-pool`. The root barrel
|
||||
(`src/index.ts`) MUST re-export them under a `multipart` namespace
|
||||
(`export * as multipart from "./multipart/index.js"`); feature barrels
|
||||
(`src/multipart/index.ts`) MUST use explicit named re-exports per
|
||||
project rule §6a — no `export *`.
|
||||
|
||||
#### Scenario: subpath import resolves
|
||||
|
||||
- **WHEN** a consumer writes
|
||||
`import { runMultipart } from "@suaveplan/storage-s3/multipart/multipart-engine"`
|
||||
- **THEN** the import resolves to the engine implementation without pulling
|
||||
the full `S3` class into the consumer's bundle
|
||||
|
||||
**Verified by:** `src/multipart/multipart-engine.test.ts::"is reachable via the dedicated subpath export"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multipart engine is verified against a real S3-compatible service
|
||||
|
||||
The package MUST exercise the multipart engine end-to-end (init, parallel
|
||||
parts, complete, abort, cleanup, error mapping) against a real S3-compatible
|
||||
service via testcontainers per project rule 2 (no mocks). The genesis
|
||||
monorepo's `@suaveplan/testing-integration-garage-s3` fixture MUST be the
|
||||
host. No unit test MAY substitute a mocked AWS SDK for these behaviours.
|
||||
|
||||
#### Scenario: integration suite runs against Garage container
|
||||
|
||||
- **WHEN** the integration test config (`vitest.integration.config.ts`) is
|
||||
executed via `bunx turbo test:integration --filter=@suaveplan/storage-s3`
|
||||
- **THEN** every test in `tests/integration/multipart.test.ts` 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/multipart.test.ts::"integration suite is wired to the Garage testcontainer fixture"`
|
||||
@@ -0,0 +1,107 @@
|
||||
# Tasks: add-storage-s3-multipart
|
||||
|
||||
## 1. Error codes + constants
|
||||
|
||||
- [ ] 1.1 Extend `S3_CODES` via `defineCodes` with `MULTIPART_INIT_FAILED`,
|
||||
`MULTIPART_PART_FAILED`, `MULTIPART_COMPLETE_FAILED`,
|
||||
`MULTIPART_ABORT_FAILED`. Retain `MULTIPART_FAILED` as a documented
|
||||
legacy alias (no new call site raises it).
|
||||
- [ ] 1.2 Add `DEFAULT_MULTIPART_THRESHOLD = 16 * 1024 * 1024` and
|
||||
`DEFAULT_MULTIPART_CONCURRENCY = 4` to `src/constants/index.ts`.
|
||||
Verify `DEFAULT_PART_SIZE` floor (≥ 5 MiB) and add a constant
|
||||
`MIN_PART_SIZE = 5 * 1024 * 1024`.
|
||||
- [ ] 1.3 Add `MultipartTuning` interface to `src/types/s3.types.ts`; extend
|
||||
`S3Options` with `multipart?: MultipartTuning`.
|
||||
|
||||
## 2. Internal engine
|
||||
|
||||
- [ ] 2.1 Create `src/multipart/part-chunker.ts` + sibling `.md` + `.test.ts` —
|
||||
`chunkStream(stream, partSize): AsyncIterable<PartInput>`; covers exact
|
||||
multiples, ragged final part, empty stream, abort mid-read.
|
||||
- [ ] 2.2 Create `src/multipart/worker-pool.ts` + sibling `.md` + `.test.ts` —
|
||||
bounded-concurrency scheduler using `Promise.race` + a linked
|
||||
`AbortController`; covers cap enforcement, first-failure aborts peers,
|
||||
caller-abort cancels in-flight workers.
|
||||
- [ ] 2.3 Create `src/multipart/multipart-engine.ts` + sibling `.md` +
|
||||
`.test.ts` — `runMultipart(client, bucket, key, parts, tuning, signal)`
|
||||
with `initMultipart` / `completePartsList` / `abortMultipart` helpers;
|
||||
maps each stage failure to its dedicated code.
|
||||
- [ ] 2.4 Create `src/multipart/index.ts` — explicit named re-exports only
|
||||
(rule 10 / §6a). No `export *`.
|
||||
|
||||
## 3. Wire into `S3.set` + `S3.setPartial`
|
||||
|
||||
- [ ] 3.1 In `s3/s3.ts`, branch `S3.set` on
|
||||
`value.byteLength > (this.options.multipart?.threshold ?? DEFAULT_MULTIPART_THRESHOLD)`
|
||||
to call the engine via a synchronous part iterator built from the
|
||||
buffer; below the threshold keep the existing `PutObjectCommand`.
|
||||
- [ ] 3.2 In `S3.setPartial`, when `offset === 0` AND
|
||||
`bytes.byteLength > threshold`, delegate to the engine (whole-object
|
||||
write); otherwise keep `defaultSetPartial`. Document in
|
||||
`s3/s3.md` that `capabilities.partialWrites` stays `false` and explain
|
||||
why.
|
||||
- [ ] 3.3 Rewrite `S3.setMultipart` as a thin wrapper over the new engine
|
||||
using `chunkStream` for the input `ReadableStream`. Preserve public
|
||||
signature; preserve `setMultipart` subpath export.
|
||||
- [ ] 3.4 Validate `partSize >= MIN_PART_SIZE` at construction time; throw
|
||||
`S3StorageError(MULTIPART_INIT_FAILED, ...)` with a clear message.
|
||||
|
||||
## 4. Subpath exports + barrels
|
||||
|
||||
- [ ] 4.1 Add `./multipart/multipart-engine`, `./multipart/part-chunker`,
|
||||
`./multipart/worker-pool` entries to `package.json` `exports`.
|
||||
- [ ] 4.2 Update `src/index.ts` to re-export the new multipart helpers via
|
||||
a namespace export (`export * as multipart from "./multipart/index.js"`)
|
||||
so consumers can reach them without breaking the root barrel rule.
|
||||
|
||||
## 5. Docs
|
||||
|
||||
- [ ] 5.1 Update `README.md` Modules + Features sections to mention auto
|
||||
multipart, tuning, and the four new error codes.
|
||||
- [ ] 5.2 Update `s3/s3.md` (Implementation Notes) — threshold rationale,
|
||||
capability honesty, abort/cleanup contract.
|
||||
- [ ] 5.3 Add `multipart/multipart-engine.md`, `part-chunker.md`,
|
||||
`worker-pool.md` per §4 (≥200 words, basic + advanced examples, API
|
||||
reference, implementation notes).
|
||||
|
||||
## 6. Integration tests (real Garage container)
|
||||
|
||||
- [ ] 6.1 Confirm `@suaveplan/testing-integration-garage-s3` fixture surface
|
||||
covers MPU operations end-to-end; if a missing capability is found
|
||||
(e.g. mid-flight `ListPartsCommand` exposure), extend the fixture in
|
||||
the same change.
|
||||
- [ ] 6.2 Add `tests/integration/multipart.test.ts` covering the 10 cases
|
||||
in `design.md#test-plan`. No mocks — use a wrapping `S3ClientFactory`
|
||||
to inject controlled delays / failures around the real Garage client.
|
||||
- [ ] 6.3 Wire integration suite into `vitest.integration.config.ts` (already
|
||||
present in the package).
|
||||
|
||||
## 7. Verify (per §13 pre-ship sequence)
|
||||
|
||||
- [ ] 7.1 Spec reconciliation (§13 step 0): every `### Requirement` in
|
||||
`specs/storage-s3/spec.md` has matching impl + tests; every
|
||||
`**Verified by:**` path resolves verbatim.
|
||||
- [ ] 7.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-s3`
|
||||
green.
|
||||
- [ ] 7.3 `bunx turbo test:coverage --filter=@suaveplan/storage-s3`
|
||||
100/100/100/100.
|
||||
- [ ] 7.4 Integration sweep: `bunx turbo test:integration --filter=@suaveplan/storage-s3`
|
||||
green against the Garage testcontainer.
|
||||
- [ ] 7.5 Dependent sweep:
|
||||
`bunx turbo test --filter=...@suaveplan/storage-s3` green.
|
||||
- [ ] 7.6 Banned-pattern / barrel-shape / runner-import scans (§13 steps
|
||||
4 / 4a / 4b) — zero output.
|
||||
- [ ] 7.7 `bun scripts/openspec-conformance.ts @suaveplan/storage-s3` PASS.
|
||||
- [ ] 7.8 Run `openspec validate add-storage-s3-multipart --strict` — must
|
||||
report PASS.
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [ ] 8.1 Refresh root docs per §13b — `MANIFEST.md`, `MISSING.yml`,
|
||||
`STATUS.md`, `SUMMARY.md`; run `bunx markdownlint-cli2 --fix`.
|
||||
- [ ] 8.2 Fold `specs/storage-s3/spec.md` into
|
||||
`openspec/specs/storage-s3/spec.md` (this is the first change to
|
||||
establish that canonical capability file — create the folder under
|
||||
`openspec/specs/` only at this archival step, never before).
|
||||
- [ ] 8.3 Move `openspec/changes/add-storage-s3-multipart/` →
|
||||
`openspec/archive/<yyyy-mm-dd>-add-storage-s3-multipart/`.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Design — `@suaveplan/webauthn-mds3`
|
||||
|
||||
## 1. Sibling package vs. in-tree module
|
||||
|
||||
**Decision:** sibling package `@suaveplan/webauthn-mds3` at `packages/auth/webauthn-mds3/`.
|
||||
|
||||
`@suaveplan/webauthn` is browser-and-universal mixed-tier and intentionally slim. Bundling MDS3 would drag four heavy concerns into it: a `FetchLike` network surface, a long-lived refresh loop (a state machine `webauthn` deliberately avoids), JWS chain verification (`@suaveplan/jws` + `@suaveplan/x509`), and a multi-megabyte FIDO Alliance metadata blob in test fixtures. Every consumer that only needs `register` / `authenticate` would pay that cost. A sibling keeps the integration opt-in, lets MDS3 evolve on its own release cadence (the FIDO Alliance ships schema bumps independently of WebAuthn), and lets server-side relying-party deployments depend on `webauthn-mds3` without touching the browser-tier surface at all.
|
||||
|
||||
The seam back into `webauthn` is a single injected interface (`MetadataResolver`) — type-only at the `webauthn` side via `import type`, so the runtime dep graph stays acyclic.
|
||||
|
||||
## 2. Package layout
|
||||
|
||||
```text
|
||||
packages/auth/webauthn-mds3/
|
||||
package.json # subpath exports per §6
|
||||
README.md
|
||||
src/
|
||||
constants/
|
||||
fido-root.ts + .md # pinned root CA DER + SHA-256, defaults
|
||||
errors/
|
||||
error-codes.ts + .md # defineCodes("WEBAUTHN_MDS3", [...])
|
||||
errors.test.ts
|
||||
schemas/
|
||||
metadata-blob.schemas.ts + .md # Zod for the whole MDS3 v3 surface
|
||||
fetch/
|
||||
fetch-blob.ts + .md + .test.ts
|
||||
verify/
|
||||
verify-blob.ts + .md + .test.ts
|
||||
cache/
|
||||
metadata-cache.ts + .md + .test.ts
|
||||
refresher/
|
||||
metadata-refresher.ts + .md + .test.ts
|
||||
resolver/
|
||||
types.ts + .md # MetadataResolver, MetadataLookup
|
||||
memory.ts + .md + .test.ts
|
||||
cached.ts + .md + .test.ts
|
||||
observability/
|
||||
metrics.ts + .md + .test.ts
|
||||
__fixtures__/
|
||||
mds3-sample-blob.jwt
|
||||
mds3-corrupted-blob.jwt
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
verify-blob.bench.ts
|
||||
resolve-aaguid.bench.ts
|
||||
index.ts # explicit named re-exports (rule 10)
|
||||
```
|
||||
|
||||
## 3. Key types
|
||||
|
||||
```ts
|
||||
// src/resolver/types.ts
|
||||
export interface MetadataLookup {
|
||||
readonly statement: MetadataStatement;
|
||||
readonly currentStatus: AuthenticatorStatus;
|
||||
readonly statusReports: readonly StatusReport[];
|
||||
readonly attestationRootCertificates: readonly Uint8Array[]; // DER
|
||||
readonly aaguid?: Uint8Array; // 16 bytes
|
||||
readonly attestationCertificateKeyIds?: readonly Uint8Array[]; // SHA-1 SKIs
|
||||
readonly lastUpdate: number; // epoch ms
|
||||
}
|
||||
|
||||
export interface MetadataResolver {
|
||||
resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>;
|
||||
resolveByAttestationCertificateKeyId(
|
||||
keyId: Uint8Array,
|
||||
): Promise<MetadataLookup | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// new optional field on @suaveplan/webauthn's VerifyAttestationInput
|
||||
export interface VerifyAttestationInput {
|
||||
// ...existing fields...
|
||||
readonly metadata?: import("@suaveplan/webauthn-mds3").MetadataResolver;
|
||||
readonly revokedStatuses?: ReadonlySet<AuthenticatorStatus>; // override default
|
||||
}
|
||||
|
||||
// new optional field on AttestationVerifyResult
|
||||
export interface AttestationVerifyResult {
|
||||
readonly trusted: boolean;
|
||||
readonly attestationType: AttestationType;
|
||||
readonly trustPath: readonly Uint8Array[];
|
||||
readonly metadata?: MetadataLookup; // present only when resolver injected
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Refresh policy
|
||||
|
||||
| Trigger | Behaviour |
|
||||
|---|---|
|
||||
| First start (no cached blob) | Fetch immediately, verify, cache, schedule next at `nextUpdate - jitter` |
|
||||
| Scheduled tick | Fetch, verify, write-through cache; on verify-fail keep prior payload and emit `webauthn_mds3.refresh_total{outcome=verify_failed}` |
|
||||
| `nextUpdate` absent | Fall back to `intervalMs` (default 24h) |
|
||||
| Concurrent call while refresh in flight | Throw `REFRESHER_ALREADY_RUNNING` (no silent dedup — caller-visible) |
|
||||
| `signal.aborted` | Cancel pending refresh, throw `REFRESHER_ABORTED` |
|
||||
| Jitter | Uniform random in `[-5min, +5min]` to avoid thundering herd against the MDS endpoint |
|
||||
|
||||
The refresher is decoupled from the resolver — a resolver can be backed by a cache that some other process refreshes (e.g. a cron job, a Cloudflare Cron Trigger). `createCachedResolver` accepts an optional `refresher` that it `start()`s on first `resolve*` call.
|
||||
|
||||
## 5. Cache contract
|
||||
|
||||
The cache is a thin wrapper around `StorageLike<MetadataBlobPayload>` from `@suaveplan/types`:
|
||||
|
||||
- Key namespace `webauthn-mds3:blob:v1` — bump the trailing version on any schema migration.
|
||||
- Single key per environment (one blob = one MDS3 endpoint). Multi-tenancy is the consumer's problem — wrap the `StorageLike` with a tenant-prefixing decorator.
|
||||
- Read failures surface `CACHE_READ_FAILED` with the underlying cause attached; the resolver MAY treat a missing cache entry as "fetch now" if a refresher is wired.
|
||||
- Writes are atomic from the resolver's perspective — the cache writes the verified payload only after `verifyMetadataBlob` resolves successfully.
|
||||
|
||||
## 6. Attestation-time lookup flow
|
||||
|
||||
```text
|
||||
verifyAttestation(input):
|
||||
1. result = await registry.get(input.attestationObject.fmt)(...)
|
||||
2. if input.metadata === undefined: return result // strict back-compat
|
||||
3. lookup =
|
||||
(fmt === "fido-u2f" || attestedCredentialData?.aaguid is all-zero)
|
||||
? input.metadata.resolveByAttestationCertificateKeyId(ski(result.trustPath[0]))
|
||||
: input.metadata.resolveByAaguid(attestedCredentialData.aaguid)
|
||||
4. if lookup === undefined: return { ...result, metadata: undefined }
|
||||
5. revoked = (input.revokedStatuses ?? DEFAULT_REVOCATION_STATUSES).has(lookup.currentStatus)
|
||||
6. return { ...result, trusted: result.trusted && !revoked, metadata: lookup }
|
||||
```
|
||||
|
||||
`DEFAULT_REVOCATION_STATUSES = new Set(["REVOKED", "USER_KEY_REMOTE_COMPROMISE", "USER_KEY_PHYSICAL_COMPROMISE", "ATTESTATION_KEY_COMPROMISE", "USER_VERIFICATION_BYPASS"])`.
|
||||
|
||||
`fido-u2f` authenticators are MDS3-keyed by attestation-cert SKI (FIDO MDS3 §3.1.6) because they predate AAGUID. The attestation cert SKI is computed inside `webauthn-mds3` so `webauthn` doesn't need an X.509 parser at runtime.
|
||||
|
||||
## 7. Security model
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Blob substitution (attacker serves a benign blob omitting a compromised authenticator) | Pin the FIDO Alliance root CA in `constants/fido-root.ts`; verify the JWS `x5c` chain terminates at the pinned root; reject any chain ending elsewhere |
|
||||
| Algorithm downgrade | Allow-list RS256 / ES256 / ES384 only; reject `none`, `HS256`, and any other alg with `BLOB_ALG_DISALLOWED` |
|
||||
| Stale blob masking a known-compromised authenticator | Reject when `now > nextUpdate + grace`; the refresher pulls new blobs on schedule; OTel exposes `webauthn_mds3.blob_age_ms` for monitoring |
|
||||
| Replay of a previous blob (`no` rollback) | Refuse to overwrite the cached blob with a payload whose `no` field is lower than the cached `no` |
|
||||
| Schema drift | All blob fields pass through Zod (`MetadataBlobPayloadSchema`); unknown fields are tolerated but typed-out — additive FIDO Alliance extensions don't crash the verifier |
|
||||
| TOCTOU between resolve and trust decision | The trust decision happens inline in `verifyAttestation`; the resolver returns a `MetadataLookup` snapshot, not a live handle, so concurrent refreshes can't flip the result mid-flight |
|
||||
| `FetchLike` impersonation in tests | Production wires a real `fetch`; tests inject deterministic stubs returning fixture bytes — no live network, no `vi.mock` (per genesis rule 2) |
|
||||
|
||||
## 8. Companion delta to `@suaveplan/webauthn`
|
||||
|
||||
Because the `VerifyAttestationInput` and `AttestationVerifyResult` shapes gain optional fields and the post-verifier flow grows two trust-altering steps, the integration is captured as a `## MODIFIED Requirements` block in `specs/webauthn/spec.md` rather than left implicit. The modified requirement keeps every original scenario (the back-compat path) and adds two new scenarios (resolver-injected augmentation; revoked-status downgrade), each with a `**Verified by:**` line pointing into the existing `verify-attestation.test.ts` (extended in task 13.4).
|
||||
|
||||
## 9. Tier purity
|
||||
|
||||
`@suaveplan/webauthn-mds3` is **universal-tier**:
|
||||
|
||||
- No `globalThis.fetch` reference — `FetchLike` is injected.
|
||||
- No `localStorage` / `IndexedDB` — `StorageLike` is injected.
|
||||
- No `Date.now()` — `Clock` is injected.
|
||||
- No DOM types in the public surface — `MetadataLookup` carries `Uint8Array` not `Blob`.
|
||||
- `tests/dep-graph.test.ts` (in the package) enforces zero DOM imports.
|
||||
|
||||
This means server-side relying parties (Bun, Node, Workers, Deno) and browser-tier consumers can both depend on it without polyfills.
|
||||
|
||||
## 10. What this change deliberately does NOT do
|
||||
|
||||
- Does **not** ship an MDS3 publishing tool (creating blobs is for the FIDO Alliance only).
|
||||
- Does **not** ship a CRL / OCSP poller for attestation roots — that's a separate trust-chain concern outside the FIDO metadata contract.
|
||||
- Does **not** wire MDS3 into the `vault` unlock path — `verifyAssertion` (the unlock path) does not consume attestation, so MDS3 has no role there.
|
||||
@@ -0,0 +1,126 @@
|
||||
# `@suaveplan/webauthn-mds3` — FIDO Metadata Service v3 integration
|
||||
|
||||
**Package**: `@suaveplan/webauthn-mds3` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (genesis-original)
|
||||
**Date**: 2026-05-17
|
||||
**Path**: `packages/auth/webauthn-mds3/`
|
||||
**Design**: ./design.md
|
||||
**Spec delta**: ./specs/webauthn-mds3/spec.md
|
||||
**Companion delta**: ./specs/webauthn/spec.md (`## MODIFIED Requirements`)
|
||||
|
||||
## Why
|
||||
|
||||
The `enterprise-webauthn` change (archived 2026-05-11) shipped `verifyAttestation` returning a `trustPath: X509[]` plus an `attestationType`, but explicitly deferred FIDO Metadata Service v3 (MDS3) integration to a follow-up:
|
||||
|
||||
> "MDS3 (FIDO Metadata Service) integration — `verifyAttestation` returns the `trustPath: X509[]` and `attestationType` so callers can perform MDS3 lookups against their own metadata source. Bundling MDS3 fetch + caching expands scope into a separate package and is a follow-up."
|
||||
> — `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md`, "Out of scope"
|
||||
|
||||
Without MDS3 a relying party cannot answer the production-critical questions every passkey deployment eventually has to answer: is this AAGUID a known certified authenticator, is its certification still valid, is it on the revocation list, what attestation root CAs are trusted for this AAGUID, and what attachment / user-verification / key-protection statuses did the FIDO Alliance certify it at. Today every genesis consumer that wants those answers has to hand-roll the MDS3 blob fetch, JWS verification, FIDO root-CA pin, AAGUID lookup, and refresh schedule — exactly the duplication this monorepo exists to eliminate.
|
||||
|
||||
This change introduces a sibling package, `@suaveplan/webauthn-mds3`, that owns the MDS3 wire protocol end-to-end (download, JWS verify against the pinned FIDO Alliance root, cache, periodic refresh, AAGUID-keyed lookup) and exposes an injectable `MetadataResolver` that `@suaveplan/webauthn`'s attestation pipeline consumes through a new optional seam. Absent injection, the existing `verifyAttestation` contract is preserved byte-for-byte (strict back-compat).
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/webauthn-mds3`
|
||||
|
||||
Universal-tier package at `packages/auth/webauthn-mds3/`. No browser-only or Node-only APIs — `FetchLike` and `StorageLike` are injected from `@suaveplan/types`.
|
||||
|
||||
#### MDS3 blob download + JWS verification
|
||||
|
||||
- ADDED: `fetchMetadataBlob({ fetch, url? }): Promise<RawBlob>` — fetches the MDS3 JWT (`application/jwt`) from `url` (default `https://mds.fidoalliance.org/`) using the injected `FetchLike` so consumers can layer in retries, proxies, or a Cloudflare-Workers `fetch`.
|
||||
- ADDED: `verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>` — verifies the compact-JWS signature via `@suaveplan/jws` using the FIDO Alliance MDS3 root CA chain. The root CA fingerprint is pinned in `constants/fido-root.ts`; consumers can override via `trustAnchors` for staging / private metadata services. Verifies `x5c` chain to root, validates not-before / not-after, enforces RS256 / ES256 alg allow-list, decodes the JSON payload, and Zod-validates it against `MetadataBlobPayloadSchema` (FIDO MDS3 §3.1.7).
|
||||
- ADDED: `MetadataBlobPayload`, `MetadataStatement`, `AuthenticatorStatus`, `StatusReport`, `BiometricStatusReport`, `EcdaaTrustAnchor` — Zod schemas tracking the published MDS3 v3 schema verbatim.
|
||||
|
||||
#### Cache + scheduled refresh
|
||||
|
||||
- ADDED: `MetadataCache` — `StorageLike<MetadataBlobPayload>`-backed cache with `legalHeader`, `no`, `nextUpdate`, and `entries` preserved. Cache key namespace `webauthn-mds3:blob:v1`.
|
||||
- ADDED: `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` — schedules refresh at `payload.nextUpdate - jitter`; falls back to `intervalMs` (default 24h) when the blob omits a `nextUpdate`. Honors `AbortSignal`; never starts a second refresh while one is in flight; emits OTel `webauthn_mds3.refresh_total{outcome}` counter.
|
||||
|
||||
#### AAGUID lookup API
|
||||
|
||||
- ADDED: `MetadataResolver` interface — `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>` (U2F authenticators are MDS3-keyed by attestation cert SKI, not AAGUID — FIDO MDS3 §3.1.6).
|
||||
- ADDED: `MetadataLookup` — `{ statement, currentStatus, statusReports, attestationRootCertificates: Uint8Array[], aaguid?, attestationCertificateKeyIds?, lastUpdate }`.
|
||||
- ADDED: `createMemoryResolver({ blob }): MetadataResolver` — synchronous in-memory resolver over a verified payload. Used by tests and by consumers that ship their own refresh loop.
|
||||
- ADDED: `createCachedResolver({ cache, refresher? }): MetadataResolver` — production resolver that reads the cache and optionally drives a refresher.
|
||||
|
||||
#### Integration seam back into `@suaveplan/webauthn`
|
||||
|
||||
- ADDED (in `@suaveplan/webauthn`): optional `metadata?: MetadataResolver` field on `VerifyAttestationInput`. When supplied, after the per-format verifier returns a `trustPath`, `verifyAttestation` resolves the AAGUID (or attestation-cert SKI for `fido-u2f`) and, when a `MetadataLookup` is returned, augments the result with `metadata: MetadataLookup` and downgrades `trusted` to `false` when `currentStatus` is `REVOKED`, `USER_KEY_REMOTE_COMPROMISE`, `USER_KEY_PHYSICAL_COMPROMISE`, `ATTESTATION_KEY_COMPROMISE`, or any other FIDO `AuthenticatorStatus` in the configurable revocation set.
|
||||
- ADDED (in `@suaveplan/webauthn`): `AttestationVerifyResult.metadata?: MetadataLookup` field. Absent when no resolver is injected — strict back-compat.
|
||||
|
||||
#### Errors
|
||||
|
||||
- ADDED: `WEBAUTHN_MDS3.*` codes via `defineCodes`: `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, `REFRESHER_ABORTED`.
|
||||
|
||||
#### Test strategy
|
||||
|
||||
- Vectors of the FIDO Alliance MDS3 sample blob (`__fixtures__/mds3-sample-blob.jwt` + parsed payload) sourced offline from the FIDO Alliance conformance suite. Tests use the `FetchLike` injection seam to return the fixture bytes — no live network in CI.
|
||||
- Negative fixtures: signature-flipped blob, chain-broken blob, expired `nextUpdate`, schema-violation payload.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
- `.` — root barrel: `MetadataResolver`, `createMemoryResolver`, `createCachedResolver`, every type, every error code.
|
||||
- `./fetch` — `fetchMetadataBlob`.
|
||||
- `./verify` — `verifyMetadataBlob`, `MetadataBlobPayloadSchema`.
|
||||
- `./cache` — `MetadataCache`, cache-key constants.
|
||||
- `./refresher` — `createMetadataRefresher`, `MetadataRefresher`.
|
||||
- `./errors` — `WEBAUTHN_MDS3_CODES`, `WebAuthnMds3Error`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@suaveplan/types workspace:^` — `FetchLike`, `StorageLike`, `Clock`, `LoggerLike`.
|
||||
- `@suaveplan/error workspace:^` — `defineCodes`, base error.
|
||||
- `@suaveplan/jws workspace:^` — JWS verification with `x5c` chain.
|
||||
- `@suaveplan/x509 workspace:^` — chain validation to pinned FIDO root.
|
||||
- `@suaveplan/crypto-hash workspace:^` — SHA-256 for AAGUID key derivation and cert thumbprints.
|
||||
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, base64url.
|
||||
- `@opentelemetry/api catalog:observability` — refresh + lookup metrics.
|
||||
- `tslib catalog:build`.
|
||||
- `zod catalog:forms` — boundary validation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **MDS v2 / MDS v1 compatibility.** MDS3 is the only metadata version on the FIDO Alliance roadmap; bundling deprecated MDS2 expands scope and ships dead code for new deployments.
|
||||
- **Per-RP custom metadata sources.** The package only speaks the FIDO Alliance MDS3 wire format. Custom metadata catalogues (enterprise allow-lists) should implement `MetadataResolver` directly — that interface is the integration seam, not the MDS3 wire format.
|
||||
- **Authenticator-status policy decisions.** The package surfaces `currentStatus` and `statusReports`; the relying party decides what status set to treat as revocation. The default revocation set is opinionated but configurable.
|
||||
- **CRL / OCSP polling for attestation-root chains.** Out of band for MDS3 — root pinning + the published metadata `nextUpdate` cadence is the FIDO-Alliance-blessed freshness model.
|
||||
|
||||
## Source mapping
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| Why an MDS3 integration is needed | `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` "Out of scope" | Verbatim deferral text |
|
||||
| `trustPath` + `attestationType` consumed downstream | `packages/auth/webauthn/src/verifier/registry.ts` L14-L18 | `AttestationVerifyResult` is the integration seam |
|
||||
| AAGUID parse + carry on attested-credential-data | `packages/auth/webauthn/src/parser/authenticator-data.ts` L73-L84 | Already available at attestation-verification time |
|
||||
| `none`/`fido-u2f`/`packed` doc anchors flag MDS3 as caller responsibility | `packages/auth/webauthn/src/attestation/{packed,fido-u2f,none}/*.md` | Today's wording moves into the `MetadataResolver` |
|
||||
| MDS3 wire format | FIDO Alliance "Metadata Service Specification" v3.0 §3.1 | External standard anchor |
|
||||
| FIDO Alliance root CA pin | <https://mds.fidoalliance.org/Root.cer> | Pinned in `constants/fido-root.ts` |
|
||||
| JWS verification | RFC 7515 + `@suaveplan/jws` | Internal contract |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in `specs/webauthn-mds3/spec.md` and every modified requirement in `specs/webauthn/spec.md` carries a `**Verified by:**` line naming planned test files. Test files (all paths relative to `packages/auth/webauthn-mds3/` unless noted):
|
||||
|
||||
- `src/fetch/fetch-blob.test.ts` — happy path via injected `FetchLike`; non-2xx surfaces `BLOB_FETCH_FAILED`; `AbortSignal` cancellation.
|
||||
- `src/verify/verify-blob.test.ts` — sample-blob signature verifies; flipped signature → `BLOB_SIGNATURE_INVALID`; broken chain → `BLOB_CHAIN_UNTRUSTED`; expired `nextUpdate` → `BLOB_EXPIRED`; HS256 → `BLOB_ALG_DISALLOWED`; schema mismatch → `BLOB_SCHEMA_MISMATCH`.
|
||||
- `src/cache/metadata-cache.test.ts` — round-trip through `StorageLike`; read/write failure mapping.
|
||||
- `src/refresher/metadata-refresher.test.ts` — refreshes at `nextUpdate - jitter`; falls back to `intervalMs`; reentrancy guard fires `REFRESHER_ALREADY_RUNNING`; `signal.abort()` fires `REFRESHER_ABORTED`; OTel counter records per outcome.
|
||||
- `src/resolver/memory.test.ts` — AAGUID lookup hit / miss; `attestationCertificateKeyId` lookup for U2F.
|
||||
- `src/resolver/cached.test.ts` — reads cache; lazy-refreshes when stale.
|
||||
- `src/errors/errors.test.ts` — every `WEBAUTHN_MDS3.*` code is thrown by at least one production path.
|
||||
- `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `"::"downgrades trusted=false when currentStatus is revoked"` AND `"::"omits metadata field when no resolver injected (back-compat)"` — integration into the existing `@suaveplan/webauthn` verifier.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/webauthn-mds3` after landing — at draft time the package is new.
|
||||
|
||||
- `@suaveplan/webauthn` — [x] tests run / [x] docs reviewed / [ ] no action needed. Gains the optional `metadata?: MetadataResolver` field on `VerifyAttestationInput` and the optional `metadata?: MetadataLookup` field on `AttestationVerifyResult`. Both are additive; existing call sites that do not inject a resolver see byte-identical behaviour.
|
||||
- `@suaveplan/vault` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: vault unlock consumes `verifyAssertion`, not `verifyAttestation`; MDS3 surfaces only on registration paths the vault doesn't drive).
|
||||
- `@suaveplan/react-auth` — [ ] tests run / [x] docs reviewed / [x] no action needed (reason: hook surface unchanged; MDS3 plugs into server-side verifier).
|
||||
|
||||
## Migration
|
||||
|
||||
No prior MDS3 surface — additive only. Behaviour change for `verifyAttestation` consumers is opt-in (only fires when `metadata: MetadataResolver` is passed). Consumers that previously open-coded an MDS3 lookup over the returned `trustPath` MAY migrate to the injected seam at their own pace; the old "ignore `metadata`, do it yourself" path is permanently supported.
|
||||
@@ -0,0 +1,222 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn-mds3 (webauthn-mds3)
|
||||
|
||||
**Package**: `@suaveplan/webauthn-mds3`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: MDS3 blob fetch via injected FetchLike
|
||||
|
||||
The package SHALL export `fetchMetadataBlob({ fetch, url?, signal? }): Promise<Uint8Array>` from the `./fetch` subpath. The function SHALL call the injected `FetchLike` (per `@suaveplan/types`) against `url` (default `https://mds.fidoalliance.org/`), return the response body as a `Uint8Array`, map any non-2xx status to `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` carrying the status code in `context.status`, and forward `signal` so caller-side cancellation triggers the underlying `AbortController` path.
|
||||
|
||||
#### Scenario: returns blob bytes on 200
|
||||
|
||||
- **WHEN** `fetchMetadataBlob({ fetch: stub200WithBytes, url: "https://example.test/mds" })` is called
|
||||
- **THEN** the resolved value `equalsBytes(stubBytes)`
|
||||
|
||||
#### Scenario: maps non-2xx to BLOB_FETCH_FAILED
|
||||
|
||||
- **WHEN** `fetchMetadataBlob({ fetch: stub503 })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` and `error.context.status === 503`
|
||||
|
||||
#### Scenario: forwards AbortSignal
|
||||
|
||||
- **GIVEN** an `AbortController` whose `signal` is passed
|
||||
- **WHEN** the controller is aborted before the stub fetch resolves
|
||||
- **THEN** the call rejects, the stub fetch received the same `signal`, and the rejection's code is `WEBAUTHN_MDS3.BLOB_FETCH_FAILED` with `cause.name === "AbortError"`
|
||||
|
||||
**Verified by:** `src/fetch/fetch-blob.test.ts::"returns blob bytes on 200"` AND `src/fetch/fetch-blob.test.ts::"maps non-2xx to BLOB_FETCH_FAILED"` AND `src/fetch/fetch-blob.test.ts::"forwards AbortSignal to the injected fetch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MDS3 blob signature verification with pinned FIDO root
|
||||
|
||||
The package SHALL export `verifyMetadataBlob({ jws, trustAnchors? }): Promise<MetadataBlobPayload>` from the `./verify` subpath. The function SHALL parse the compact JWS, enforce the algorithm allow-list `{ RS256, ES256, ES384 }` (rejecting any other with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`), validate the `x5c` certificate chain to the pinned FIDO Alliance MDS3 root CA (or to caller-supplied `trustAnchors`) via `@suaveplan/x509`, verify the signature via `@suaveplan/jws` and `@suaveplan/crypto-rsa`/`@suaveplan/crypto-curve` per the header `alg`, decode the JSON payload, Zod-validate against `MetadataBlobPayloadSchema`, and reject with `WEBAUTHN_MDS3.BLOB_EXPIRED` when `now > payload.nextUpdate`.
|
||||
|
||||
#### Scenario: verifies the FIDO sample blob
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: SAMPLE_BLOB_JWT })` is called against the bundled FIDO Alliance conformance sample
|
||||
- **THEN** the resolved value is a typed `MetadataBlobPayload` with `legalHeader`, `no`, `nextUpdate`, and at least one `entries[]` element
|
||||
|
||||
#### Scenario: rejects flipped signature
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws: CORRUPTED_BLOB_JWT })` is called against the bundled signature-flipped variant
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SIGNATURE_INVALID`
|
||||
|
||||
#### Scenario: rejects untrusted chain
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose `x5c` chain terminates at a non-pinned root
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_CHAIN_UNTRUSTED`
|
||||
|
||||
#### Scenario: rejects disallowed algorithm
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called with a JWS whose header `alg === "HS256"`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_ALG_DISALLOWED`
|
||||
|
||||
#### Scenario: rejects expired blob
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called and `Clock.now() > payload.nextUpdate`
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_EXPIRED`
|
||||
|
||||
#### Scenario: rejects schema mismatch
|
||||
|
||||
- **WHEN** `verifyMetadataBlob({ jws })` is called against a payload missing the required `entries` field
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.BLOB_SCHEMA_MISMATCH`
|
||||
|
||||
**Verified by:** `src/verify/verify-blob.test.ts::"verifies the FIDO sample blob"` AND `src/verify/verify-blob.test.ts::"rejects flipped signature"` AND `src/verify/verify-blob.test.ts::"rejects untrusted chain"` AND `src/verify/verify-blob.test.ts::"rejects disallowed algorithm"` AND `src/verify/verify-blob.test.ts::"rejects expired blob"` AND `src/verify/verify-blob.test.ts::"rejects schema mismatch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Metadata cache via injected StorageLike
|
||||
|
||||
The package SHALL export `MetadataCache` from the `./cache` subpath that wraps an injected `StorageLike<MetadataBlobPayload>` under the key namespace `webauthn-mds3:blob:v1`. The cache SHALL surface storage read failures as `WEBAUTHN_MDS3.CACHE_READ_FAILED` and write failures as `WEBAUTHN_MDS3.CACHE_WRITE_FAILED`, attaching the underlying error as `cause`. The cache SHALL refuse to write a payload whose `no` field is strictly lower than the currently-cached payload's `no` (rollback protection).
|
||||
|
||||
#### Scenario: round-trips a verified payload
|
||||
|
||||
- **WHEN** `cache.set(payload)` then `cache.get()` is called against an in-memory `StorageLike`
|
||||
- **THEN** the returned payload deep-equals the written payload
|
||||
|
||||
#### Scenario: surfaces storage read failure
|
||||
|
||||
- **GIVEN** a `StorageLike` whose `get` rejects with `new Error("disk full")`
|
||||
- **WHEN** `cache.get()` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_READ_FAILED` and `error.cause.message === "disk full"`
|
||||
|
||||
#### Scenario: refuses to write a rolled-back payload
|
||||
|
||||
- **GIVEN** a cache holding a payload with `no: 42`
|
||||
- **WHEN** `cache.set(payloadWithNo41)` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN_MDS3.CACHE_WRITE_FAILED` and `error.context.reason === "rollback"`
|
||||
|
||||
**Verified by:** `src/cache/metadata-cache.test.ts::"round-trips a verified payload"` AND `src/cache/metadata-cache.test.ts::"surfaces storage read failure as CACHE_READ_FAILED"` AND `src/cache/metadata-cache.test.ts::"refuses to write a rolled-back payload"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduled metadata refresher
|
||||
|
||||
The package SHALL export `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? }): MetadataRefresher` from the `./refresher` subpath. The refresher SHALL schedule the next refresh at `payload.nextUpdate - jitter` (uniform jitter in `[-5min, +5min]`) when `nextUpdate` is present, otherwise at `now + intervalMs` (default 24h). The refresher SHALL throw `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING` when `refresh()` is called while a refresh is in flight, and SHALL reject the pending refresh with `WEBAUTHN_MDS3.REFRESHER_ABORTED` when the injected `AbortSignal` fires.
|
||||
|
||||
#### Scenario: schedules at nextUpdate minus jitter
|
||||
|
||||
- **WHEN** a refresher is started against a payload with `nextUpdate = now + 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is in the range `[55min, 65min]` from `now`
|
||||
|
||||
#### Scenario: falls back to intervalMs when nextUpdate absent
|
||||
|
||||
- **WHEN** a refresher is started against a payload with `nextUpdate: undefined` and `intervalMs: 1h`
|
||||
- **THEN** the next `clock.setTimeout` call is at approximately `now + 1h`
|
||||
|
||||
#### Scenario: reentrancy guard fires REFRESHER_ALREADY_RUNNING
|
||||
|
||||
- **WHEN** `refresh()` is called while a prior `refresh()` is still pending
|
||||
- **THEN** the second call rejects with `WEBAUTHN_MDS3.REFRESHER_ALREADY_RUNNING`
|
||||
|
||||
#### Scenario: aborts on signal
|
||||
|
||||
- **GIVEN** a refresher started with `signal: ac.signal`
|
||||
- **WHEN** `ac.abort()` is called mid-fetch
|
||||
- **THEN** the pending refresh rejects with `WEBAUTHN_MDS3.REFRESHER_ABORTED`
|
||||
|
||||
#### Scenario: records refresh outcome counter
|
||||
|
||||
- **WHEN** a refresh completes successfully
|
||||
- **THEN** the OTel counter `webauthn_mds3.refresh_total{outcome="ok"}` increments by 1
|
||||
|
||||
**Verified by:** `src/refresher/metadata-refresher.test.ts::"schedules at nextUpdate minus jitter"` AND `src/refresher/metadata-refresher.test.ts::"falls back to intervalMs when nextUpdate absent"` AND `src/refresher/metadata-refresher.test.ts::"reentrancy guard fires REFRESHER_ALREADY_RUNNING"` AND `src/refresher/metadata-refresher.test.ts::"aborts on signal"` AND `src/refresher/metadata-refresher.test.ts::"records refresh outcome counter"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AAGUID lookup API
|
||||
|
||||
The package SHALL export a `MetadataResolver` interface with `resolveByAaguid(aaguid: Uint8Array): Promise<MetadataLookup | undefined>` and `resolveByAttestationCertificateKeyId(keyId: Uint8Array): Promise<MetadataLookup | undefined>`. The package SHALL ship two factories: `createMemoryResolver({ blob })` (synchronous in-memory over a verified payload) and `createCachedResolver({ cache, refresher? })` (production resolver that reads through the cache and optionally starts the refresher on first access). The AAGUID comparator SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils`. The `attestationCertificateKeyId` comparator SHALL match on the byte-equal SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6 — U2F authenticators have no AAGUID).
|
||||
|
||||
#### Scenario: AAGUID hit returns a typed MetadataLookup
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload containing an entry whose AAGUID is `0x11223344...`
|
||||
- **WHEN** `resolveByAaguid(0x11223344...)` is called
|
||||
- **THEN** the resolved value has `statement.aaguid === "11223344-..."`, `currentStatus` set, `attestationRootCertificates.length >= 1`, and `lastUpdate > 0`
|
||||
|
||||
#### Scenario: AAGUID miss returns undefined
|
||||
|
||||
- **WHEN** `resolveByAaguid(zerosBytes)` is called against a resolver whose payload has no zero-AAGUID entry
|
||||
- **THEN** the resolved value is `undefined`
|
||||
|
||||
#### Scenario: U2F attestation-cert SKI lookup
|
||||
|
||||
- **GIVEN** a memory resolver seeded with a payload containing an entry whose `attestationCertificateKeyIdentifiers` includes a specific SHA-1 SKI
|
||||
- **WHEN** `resolveByAttestationCertificateKeyId(thatSki)` is called
|
||||
- **THEN** the resolved value's `statement` matches that entry
|
||||
|
||||
#### Scenario: cached resolver lazy-refreshes when cache empty
|
||||
|
||||
- **GIVEN** a cached resolver whose cache is empty and whose refresher is wired
|
||||
- **WHEN** `resolveByAaguid(any)` is called
|
||||
- **THEN** the refresher's `refresh()` is invoked exactly once before the resolver returns
|
||||
|
||||
**Verified by:** `src/resolver/memory.test.ts::"AAGUID hit returns a typed MetadataLookup"` AND `src/resolver/memory.test.ts::"AAGUID miss returns undefined"` AND `src/resolver/memory.test.ts::"U2F attestation-cert SKI lookup"` AND `src/resolver/cached.test.ts::"lazy-refreshes when cache empty"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error-code registry
|
||||
|
||||
The package SHALL declare its error codes via `defineCodes("WEBAUTHN_MDS3", [...])` covering `BLOB_FETCH_FAILED`, `BLOB_MALFORMED`, `BLOB_SIGNATURE_INVALID`, `BLOB_CHAIN_UNTRUSTED`, `BLOB_EXPIRED`, `BLOB_ALG_DISALLOWED`, `BLOB_SCHEMA_MISMATCH`, `CACHE_READ_FAILED`, `CACHE_WRITE_FAILED`, `RESOLVER_LOOKUP_FAILED`, `REFRESHER_ALREADY_RUNNING`, and `REFRESHER_ABORTED`. Every declared code SHALL be thrown by at least one production call site exercised by an existing test.
|
||||
|
||||
#### Scenario: every code is reachable
|
||||
|
||||
- **WHEN** the test harness greps `src/` for `WEBAUTHN_MDS3_CODES.<CODE>` references for every declared `CODE`
|
||||
- **THEN** every code has at least one reference outside `src/errors/`
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"every WEBAUTHN_MDS3 code is thrown by at least one production path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Observability counters and histogram
|
||||
|
||||
The package SHALL emit `webauthn_mds3.refresh_total{outcome}` and `webauthn_mds3.lookup_total{outcome}` counters and a `webauthn_mds3.blob_age_ms` histogram via `@opentelemetry/api`. When no `MeterProvider` is registered the metrics SHALL no-op silently.
|
||||
|
||||
#### Scenario: refresh_total records on success
|
||||
|
||||
- **WHEN** a refresh completes successfully against an in-memory `MeterProvider`
|
||||
- **THEN** `webauthn_mds3.refresh_total{outcome="ok"}` is `1`
|
||||
|
||||
#### Scenario: lookup_total records on miss
|
||||
|
||||
- **WHEN** `resolveByAaguid` returns `undefined`
|
||||
- **THEN** `webauthn_mds3.lookup_total{outcome="miss"}` is `1`
|
||||
|
||||
#### Scenario: blob_age_ms records on read
|
||||
|
||||
- **WHEN** `cache.get()` returns a payload aged 1h
|
||||
- **THEN** `webauthn_mds3.blob_age_ms` has a recorded value in the bucket containing 3_600_000
|
||||
|
||||
**Verified by:** `src/observability/metrics.test.ts::"refresh_total records on success"` AND `src/observability/metrics.test.ts::"lookup_total records on miss"` AND `src/observability/metrics.test.ts::"blob_age_ms records on read"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The package SHALL NOT import any DOM, browser, or Node-only API. `FetchLike`, `StorageLike`, `Clock`, and `LoggerLike` SHALL be injected through the public API surface so the package runs unmodified on Bun, Node, Cloudflare Workers, Deno, and inside a browser bundle.
|
||||
|
||||
#### Scenario: dep-graph harness sees no DOM types
|
||||
|
||||
- **WHEN** the dep-graph test sweeps the public surface for `Window`, `Document`, `localStorage`, `Blob` references
|
||||
- **THEN** zero matches are found
|
||||
|
||||
**Verified by:** `src/dep-graph.test.ts::"public surface imports zero DOM or Node-only types"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL expose subpath exports for `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, and `./errors` in its `package.json` `exports` map so consumers can tree-shake unused capabilities.
|
||||
|
||||
#### Scenario: every subpath resolves
|
||||
|
||||
- **WHEN** the package's `index.test.ts` imports from each subpath
|
||||
- **THEN** every import resolves to the expected module and the bundler tree-shakes unused entries
|
||||
|
||||
**Verified by:** `src/index.test.ts::"every documented subpath resolves through the package's exports map"`
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: webauthn (webauthn)
|
||||
|
||||
**Package**: `@suaveplan/webauthn`
|
||||
**Status**: PROPOSED (companion delta of `add-webauthn-mds3`)
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Attestation verification dispatch
|
||||
|
||||
The package SHALL export `verifyAttestation(input: VerifyAttestationInput): Promise<AttestationVerifyResult>` from the `./verifier` subpath. The function SHALL dispatch on `input.attestationObject.fmt` to the per-format verifier registered in `input.registry`, throwing `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED` when no verifier is registered. `VerifyAttestationInput` SHALL accept an optional `metadata?: MetadataResolver` field (typed via `import type` from `@suaveplan/webauthn-mds3` — runtime-optional) and an optional `revokedStatuses?: ReadonlySet<AuthenticatorStatus>` field. `AttestationVerifyResult` SHALL carry an optional `metadata?: MetadataLookup` field. When `input.metadata` is `undefined` the returned `AttestationVerifyResult` SHALL be byte-identical to the pre-modification contract (strict back-compat). When `input.metadata` is supplied, after the per-format verifier returns, `verifyAttestation` SHALL resolve the AAGUID — or, for `fmt === "fido-u2f"` or an all-zero AAGUID, the SHA-1 SKI of the leaf attestation certificate (FIDO MDS3 §3.1.6) — call `resolveByAaguid` / `resolveByAttestationCertificateKeyId` on the supplied resolver, set `result.metadata` to the returned `MetadataLookup` (or omit when `undefined`), and downgrade `result.trusted` to `false` when the lookup's `currentStatus` is in `revokedStatuses ?? DEFAULT_REVOCATION_STATUSES`.
|
||||
|
||||
#### Scenario: dispatches to registered format verifier
|
||||
|
||||
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "packed", ... }, registry: defaultAttestationRegistry(), ... })` is called
|
||||
- **THEN** the result is produced by the `packed` verifier and includes `trustPath`, `attestationType`, `trusted`
|
||||
|
||||
#### Scenario: throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt
|
||||
|
||||
- **WHEN** `verifyAttestation({ attestationObject: { fmt: "unknown-fmt", ... }, registry: emptyRegistry })` is called
|
||||
- **THEN** the call rejects with `WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED`
|
||||
|
||||
#### Scenario: omits metadata field when no resolver injected (back-compat)
|
||||
|
||||
- **WHEN** `verifyAttestation(input)` is called with `input.metadata === undefined`
|
||||
- **THEN** the resolved `AttestationVerifyResult` has `metadata === undefined` and is byte-for-byte identical to the legacy (pre-modification) result for the same inputs
|
||||
|
||||
#### Scenario: augments result with metadata when resolver injected
|
||||
|
||||
- **GIVEN** a `MetadataResolver` whose `resolveByAaguid` returns a `MetadataLookup` with `currentStatus === "FIDO_CERTIFIED_L1"`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called against a `packed` attestation carrying a known AAGUID
|
||||
- **THEN** `result.metadata` deep-equals the lookup and `result.trusted` is the verifier's original value (unchanged)
|
||||
|
||||
#### Scenario: downgrades trusted=false when currentStatus is revoked
|
||||
|
||||
- **GIVEN** a `MetadataResolver` whose `resolveByAaguid` returns a `MetadataLookup` with `currentStatus === "REVOKED"`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called and the per-format verifier returned `trusted: true`
|
||||
- **THEN** the merged `result.trusted` is `false` and `result.metadata.currentStatus === "REVOKED"`
|
||||
|
||||
#### Scenario: resolves U2F by attestation-cert SKI
|
||||
|
||||
- **GIVEN** a `MetadataResolver` and a `fido-u2f` attestation whose leaf cert has SKI `0xDEAD…`
|
||||
- **WHEN** `verifyAttestation({ ..., metadata: resolver })` is called
|
||||
- **THEN** the resolver's `resolveByAttestationCertificateKeyId(0xDEAD…)` is invoked exactly once and `resolveByAaguid` is never called
|
||||
|
||||
**Verified by:** `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"dispatches to registered format verifier"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"throws WEBAUTHN.ATTESTATION_FORMAT_UNSUPPORTED on unknown fmt"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"omits metadata field when no resolver injected (back-compat)"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"augments result with metadata when resolver injected"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"downgrades trusted=false when currentStatus is revoked"` AND `packages/auth/webauthn/src/verifier/verify-attestation.test.ts::"resolves U2F by attestation-cert SKI"`
|
||||
@@ -0,0 +1,98 @@
|
||||
# Tasks — `add-webauthn-mds3`
|
||||
|
||||
## 1. Package scaffolding
|
||||
|
||||
- [ ] 1.1 Create `packages/auth/webauthn-mds3/` per genesis package layout.
|
||||
- [ ] 1.2 `package.json` at `version: 0.0.0`, `sideEffects: false`, `workspace:^` for internal deps, `catalog:*` for external.
|
||||
- [ ] 1.3 `tsconfig.json` extending `@suaveplan/typescript-config` universal preset.
|
||||
- [ ] 1.4 `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`; coverage thresholds 100 / 100 / 100 / 100; `provider: 'istanbul'`.
|
||||
- [ ] 1.5 Subpath exports map in `package.json`: `.`, `./fetch`, `./verify`, `./cache`, `./refresher`, `./errors`.
|
||||
- [ ] 1.6 Root `README.md` with one-paragraph description, install, quick-start, Modules section linking to every co-located `.md`.
|
||||
|
||||
## 2. Constants + FIDO root pinning
|
||||
|
||||
- [ ] 2.1 `src/constants/fido-root.ts` + `.md` — pin the FIDO Alliance MDS3 root CA DER bytes + SHA-256 fingerprint; export `FIDO_MDS3_DEFAULT_URL`, `DEFAULT_REFRESH_INTERVAL_MS`, `DEFAULT_REVOCATION_STATUSES`.
|
||||
|
||||
## 3. Error codes
|
||||
|
||||
- [ ] 3.1 `src/errors/error-codes.ts` + `.md` — `defineCodes("WEBAUTHN_MDS3", [...])` with every code from the spec delta; export `WebAuthnMds3Error`.
|
||||
- [ ] 3.2 `src/errors/errors.test.ts` — assert every code is reachable from a production path (call-site grep harness).
|
||||
|
||||
## 4. Schemas
|
||||
|
||||
- [ ] 4.1 `src/schemas/metadata-blob.schemas.ts` + `.md` — `MetadataBlobPayloadSchema`, `MetadataStatementSchema`, `AuthenticatorStatusSchema`, `StatusReportSchema`, `BiometricStatusReportSchema`, `EcdaaTrustAnchorSchema`, `MetadataBlobEntrySchema` tracking FIDO MDS3 §3.1.
|
||||
|
||||
## 5. Fetch
|
||||
|
||||
- [ ] 5.1 `src/fetch/fetch-blob.ts` + `.md` — `fetchMetadataBlob({ fetch, url?, signal? })`; maps non-2xx → `BLOB_FETCH_FAILED`; honors `AbortSignal`.
|
||||
- [ ] 5.2 `src/fetch/fetch-blob.test.ts` — happy path; non-2xx; abort.
|
||||
|
||||
## 6. Verify
|
||||
|
||||
- [ ] 6.1 `src/verify/verify-blob.ts` + `.md` — verify compact JWS via `@suaveplan/jws`; pin alg allow-list (RS256, ES256, ES384); validate `x5c` chain to FIDO root via `@suaveplan/x509`; check `nextUpdate`; decode + Zod-validate payload.
|
||||
- [ ] 6.2 `src/verify/verify-blob.test.ts` — happy, signature-flipped, chain-broken, expired, alg-disallowed, schema-mismatch (use sample-blob fixture).
|
||||
|
||||
## 7. Cache
|
||||
|
||||
- [ ] 7.1 `src/cache/metadata-cache.ts` + `.md` — wraps injected `StorageLike<MetadataBlobPayload>` with key namespace `webauthn-mds3:blob:v1`; surfaces `CACHE_READ_FAILED` / `CACHE_WRITE_FAILED`.
|
||||
- [ ] 7.2 `src/cache/metadata-cache.test.ts` — round-trip; injected storage failure mapping.
|
||||
|
||||
## 8. Refresher
|
||||
|
||||
- [ ] 8.1 `src/refresher/metadata-refresher.ts` + `.md` — `createMetadataRefresher({ fetch, storage, clock, logger, intervalMs?, signal? })`; reentrancy guard; OTel counter; jittered scheduling at `nextUpdate - jitter`.
|
||||
- [ ] 8.2 `src/refresher/metadata-refresher.test.ts` — schedule, reentrancy guard, abort, OTel counter, fallback to `intervalMs`.
|
||||
|
||||
## 9. Resolver
|
||||
|
||||
- [ ] 9.1 `src/resolver/types.ts` + `.md` — `MetadataResolver`, `MetadataLookup` interfaces (no name collisions per AGENTS.md §10 — folder is `resolver/`, files use feature names not `*-resolver.ts`).
|
||||
- [ ] 9.2 `src/resolver/memory.ts` + `.md` + `memory.test.ts` — `createMemoryResolver` for verified payloads.
|
||||
- [ ] 9.3 `src/resolver/cached.ts` + `.md` + `cached.test.ts` — `createCachedResolver` reading the cache + optional refresher drive.
|
||||
|
||||
## 10. Observability
|
||||
|
||||
- [ ] 10.1 `src/observability/metrics.ts` + `.md` — `webauthn_mds3.refresh_total{outcome}`, `webauthn_mds3.lookup_total{outcome}` counters; `webauthn_mds3.blob_age_ms` histogram. No-op when no exporter.
|
||||
- [ ] 10.2 `src/observability/metrics.test.ts` — every metric records on its production path against an in-memory `MeterProvider`.
|
||||
|
||||
## 11. Fixtures
|
||||
|
||||
- [ ] 11.1 `src/__fixtures__/mds3-sample-blob.jwt` — captured FIDO Alliance MDS3 conformance-suite sample blob.
|
||||
- [ ] 11.2 `src/__fixtures__/mds3-corrupted-blob.jwt` — signature-flipped variant for negative tests.
|
||||
- [ ] 11.3 `src/__fixtures__/index.ts` — typed fixture exports.
|
||||
|
||||
## 12. Benchmarks
|
||||
|
||||
- [ ] 12.1 `src/__benchmarks__/verify-blob.bench.ts` — JWS verify throughput on the sample blob.
|
||||
- [ ] 12.2 `src/__benchmarks__/resolve-aaguid.bench.ts` — AAGUID lookup throughput.
|
||||
|
||||
## 13. Integration into `@suaveplan/webauthn`
|
||||
|
||||
- [ ] 13.1 Extend `VerifyAttestationInput` with optional `metadata?: MetadataResolver` field (typed via `import type` from `@suaveplan/webauthn-mds3` — no runtime dep).
|
||||
- [ ] 13.2 Extend `AttestationVerifyResult` with optional `metadata?: MetadataLookup` field.
|
||||
- [ ] 13.3 Wire post-verifier resolution: AAGUID for non-U2F formats, attestation-cert SKI for `fido-u2f`; downgrade `trusted=false` on revoked statuses; configurable revocation set with default from `DEFAULT_REVOCATION_STATUSES`.
|
||||
- [ ] 13.4 Update `packages/auth/webauthn/src/verifier/verify-attestation.test.ts` — three new tests: resolver injected augments result; revoked status downgrades trust; no resolver = byte-identical legacy behaviour.
|
||||
- [ ] 13.5 Refresh `packages/auth/webauthn/src/verifier/verify-attestation.md` and the three attestation doc anchors (`packed.md`, `fido-u2f.md`, `none.md`) so the "caller wires MDS3" language now mentions `MetadataResolver` as the canonical seam.
|
||||
- [ ] 13.6 Add `@suaveplan/webauthn-mds3` to `packages/auth/webauthn/README.md` Modules section as the recommended MDS3 sidecar.
|
||||
|
||||
## 14. Pre-publish gate
|
||||
|
||||
- [ ] 14.1 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn-mds3` green.
|
||||
- [ ] 14.2 `bunx turbo test:coverage --filter=@suaveplan/webauthn-mds3` 100 / 100 / 100 / 100.
|
||||
- [ ] 14.3 `bunx turbo lint typecheck test --filter=@suaveplan/webauthn` green (dependent sweep — §13 step 10).
|
||||
- [ ] 14.4 Banned-pattern scan: zero matches per §13 step 4.
|
||||
- [ ] 14.5 Barrel-shape scan (§13 step 4a): zero matches.
|
||||
- [ ] 14.6 Test-runner import scan (§13 step 4b): zero matches.
|
||||
- [ ] 14.7 Missing-`.md` scan: zero misses.
|
||||
- [ ] 14.8 `bunx markdownlint-cli2 --fix` then second pass with no `--fix` exits 0.
|
||||
|
||||
## 15. Repo-root manifest refresh (§13b)
|
||||
|
||||
- [ ] 15.1 `bun scripts/folder-reorg/generate-manifest.ts` → `MANIFEST.md` refreshed.
|
||||
- [ ] 15.2 `bun scripts/generate-missing.ts` → `MISSING.yml` refreshed.
|
||||
- [ ] 15.3 `STATUS.md` updated: add `@suaveplan/webauthn-mds3` row.
|
||||
- [ ] 15.4 `SUMMARY.md` paragraph for `webauthn-mds3` added.
|
||||
|
||||
## 16. Fold + archive
|
||||
|
||||
- [ ] 16.1 Fold `specs/webauthn-mds3/spec.md` into `openspec/specs/webauthn-mds3/spec.md`.
|
||||
- [ ] 16.2 Fold `specs/webauthn/spec.md` `## MODIFIED Requirements` into the canonical `openspec/specs/webauthn/spec.md`.
|
||||
- [ ] 16.3 Move `openspec/changes/add-webauthn-mds3/` → `openspec/archive/<yyyy-mm-dd>-add-webauthn-mds3/`.
|
||||
@@ -0,0 +1,88 @@
|
||||
# OpenSpec Change: enterprise-react-auth
|
||||
|
||||
- **Change ID**: enterprise-react-auth
|
||||
- **Package**: `@suaveplan/react-auth`
|
||||
- **Category**: react
|
||||
- **Target path**: `react/auth/react-auth`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-04-24
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/react-auth.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/react-auth` on genesis from scratch at enterprise quality. The core React authentication layer: `AuthProvider`, `useAuth`, `useUser`, `useIsAuthenticated`, token management, session persistence, and integration with `@suaveplan/oauth-client` for Authorization Code + PKCE flows. Ships structured errors via `@suaveplan/error`, OTel span events for sign-in/sign-out/refresh, Storybook stories, and Playwright E2E for the full auth lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
Package does not exist on genesis — fresh build.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/error` (`workspace:^`) | `AuthError` |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
|
||||
| `@suaveplan/events` (`workspace:^`) | Auth state change events |
|
||||
| `@suaveplan/oauth-client` (`workspace:^`) | PKCE + token refresh |
|
||||
| `@suaveplan/react-translate` (`workspace:^`) | i18n |
|
||||
| `zod` (`catalog:forms`) | Auth config schemas |
|
||||
| `@opentelemetry/api` (`catalog:observability`) | Auth lifecycle spans |
|
||||
|
||||
External: `tslib catalog:build`. React 18 peer.
|
||||
|
||||
---
|
||||
|
||||
## Public API surface
|
||||
|
||||
```ts
|
||||
AuthProvider, AuthContext
|
||||
useAuth, useUser, useIsAuthenticated, useAuthToken
|
||||
AuthGuard — wraps children, redirects to login if unauthenticated
|
||||
withAuth(Component) — HOC variant of AuthGuard
|
||||
|
||||
// Types
|
||||
AuthConfig, AuthUser, AuthState, AuthContextValue,
|
||||
AuthProviderProps, AuthGuardProps, SignInOptions, SignOutOptions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis (full build)
|
||||
|
||||
1. **`AuthError`** — codes: `AUTH_UNAUTHENTICATED`, `AUTH_TOKEN_EXPIRED`, `AUTH_REFRESH_FAILED`, `AUTH_CONTEXT_MISSING`.
|
||||
2. **Token refresh** via `@suaveplan/oauth-client` with automatic retry on 401.
|
||||
3. **OTel spans** — `auth.sign_in`, `auth.sign_out`, `auth.token_refresh`.
|
||||
4. **Session persistence** — optional `sessionStorage` / `localStorage` adapter.
|
||||
5. **Stories** — authenticated state, unauthenticated, loading, `AuthGuard` redirect.
|
||||
6. **Playwright E2E** — sign-in flow, token refresh, protected route redirect.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
react/auth/react-auth/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
context/
|
||||
auth-context/ (tsx + types + md + test + index)
|
||||
hooks/
|
||||
use-auth/ use-user/ use-is-authenticated/ use-auth-token/
|
||||
components/
|
||||
auth-guard/ (tsx + types + md + test + stories + index)
|
||||
utils/
|
||||
token-storage/ session-persistence/
|
||||
types/
|
||||
auth.types.ts
|
||||
e2e/
|
||||
auth.spec.ts
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Tasks: enterprise-react-auth
|
||||
|
||||
**Change ID**: enterprise-react-auth
|
||||
**Package**: `@suaveplan/react-auth`
|
||||
**Target path**: `react/auth/react-auth`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/oauth-client`, `@suaveplan/react-translate`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create directory tree per proposal layout.
|
||||
- [ ] Write `package.json` at `0.1.0` with `"sideEffects": false`, exports map.
|
||||
- [ ] Dependencies: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/oauth-client workspace:^`, `@suaveplan/react-translate workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`.
|
||||
- [ ] Write `tsconfig.json`, `vitest.config.ts`, scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Error codes and types
|
||||
|
||||
- [ ] Define `AuthError` extending `SuaveplanError`; codes: `AUTH_UNAUTHENTICATED`, `AUTH_TOKEN_EXPIRED`, `AUTH_REFRESH_FAILED`, `AUTH_CONTEXT_MISSING`.
|
||||
- [ ] Define `AuthUser`, `AuthState`, `AuthConfig`, `AuthContextValue`, `AuthProviderProps`, `AuthGuardProps`, `SignInOptions`, `SignOutOptions`.
|
||||
- [ ] Write `AuthConfigSchema` Zod schema.
|
||||
|
||||
---
|
||||
|
||||
## 3. Token utilities
|
||||
|
||||
- [ ] `src/utils/token-storage/` — `createTokenStorage(adapter: 'memory' | 'localStorage' | 'sessionStorage')`: `setTokens(tokens)`, `getTokens()`, `clearTokens()`.
|
||||
- [ ] `src/utils/session-persistence/` — `saveSession(user)`, `loadSession(): AuthUser | null`, `clearSession()`.
|
||||
- [ ] Write tests + md for each.
|
||||
|
||||
---
|
||||
|
||||
## 4. AuthContext + AuthProvider
|
||||
|
||||
- [ ] State: `user: AuthUser | null`, `isLoading`, `isAuthenticated`.
|
||||
- [ ] `signIn(options: SignInOptions)` — calls `@suaveplan/oauth-client` authorization + token exchange; stores tokens; emits `@suaveplan/events` `auth:signed_in`. OTel `auth.sign_in` span.
|
||||
- [ ] `signOut(options?)` — clears tokens + session; emits `auth:signed_out`. OTel `auth.sign_out` span.
|
||||
- [ ] `refreshToken()` — calls `@suaveplan/oauth-client` token refresh; on failure throws `AuthError(AUTH_REFRESH_FAILED)`. OTel `auth.token_refresh` span.
|
||||
- [ ] Auto-refresh: installs a timer to refresh before expiry.
|
||||
- [ ] `useAuth()` throws `AuthError(AUTH_CONTEXT_MISSING)` outside provider.
|
||||
- [ ] Write `auth-context.test.tsx` (≤500 lines): sign-in, sign-out, refresh, auto-refresh timer, context missing. `{ timeout: 10000 }`.
|
||||
- [ ] Write `auth-context.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hooks
|
||||
|
||||
- [ ] `useUser(): AuthUser | null` — from context.
|
||||
- [ ] `useIsAuthenticated(): boolean`.
|
||||
- [ ] `useAuthToken(): string | null` — current access token.
|
||||
- [ ] Write tests + md for each.
|
||||
|
||||
---
|
||||
|
||||
## 6. AuthGuard + withAuth
|
||||
|
||||
- [ ] `AuthGuard` — renders children when authenticated; calls `onUnauthenticated` (default: throws `AuthError(AUTH_UNAUTHENTICATED)`) when not.
|
||||
- [ ] `withAuth(Component)` — HOC wrapping `AuthGuard`.
|
||||
- [ ] Write tests, stories (authenticated, unauthenticated-redirect, loading), md.
|
||||
|
||||
---
|
||||
|
||||
## 7. E2E
|
||||
|
||||
- [ ] `e2e/auth.spec.ts` — Playwright with injected mock `@suaveplan/oauth-client`:
|
||||
- Render `AuthGuard` while unauthenticated → `onUnauthenticated` fires.
|
||||
- Call `signIn()` → user set in context → `AuthGuard` renders children.
|
||||
- Call `signOut()` → user cleared.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verify and publish
|
||||
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/react-auth` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/react-auth` — 100%.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/react-auth`.
|
||||
- [ ] Archive change folder.
|
||||
@@ -0,0 +1,232 @@
|
||||
# Design — hoist-drivelike-to-types
|
||||
|
||||
Non-normative architecture notes. Normative requirements live in `./specs/types/spec.md` and `./specs/drive/spec.md`.
|
||||
|
||||
## 1. Why now (≥2-consumer rule satisfied)
|
||||
|
||||
`AGENTS.md` §0 step 6 and CLAUDE.md §11 say: a type referenced by ≥ 2 packages belongs in `@suaveplan/types`. Until today, `DriveLike` had exactly one consumer (`drive-uri`), so the archived `add-drive-uri` proposal correctly deferred the hoist. The current change folder is being authored alongside a sibling proposal `add-drive-uri-writes` that adds write-path URI plugins consuming the same structural shape. With those two in flight plus the obvious future `drive-mux` / `drive-fanout` composition consumers, the rule is satisfied and the hoist is the right next step.
|
||||
|
||||
Explicit consumer enumeration (the ≥ 2 the rule needs):
|
||||
|
||||
1. `@suaveplan/drive-uri` (archived `add-drive-uri`, shipped) — uses `DriveLike` for read-path URI plugins.
|
||||
2. `@suaveplan/drive-uri` writes plugins (parallel `add-drive-uri-writes`) — uses `DriveLike` for write-path URI plugins.
|
||||
3. Future `drive-mux` / `drive-fanout` packages — will compose multiple `DriveLike` instances without taking on the concrete `Drive` runtime.
|
||||
|
||||
Two consumers is the floor; three is the case actually in flight.
|
||||
|
||||
## 2. The canonical type surface (verbatim TS)
|
||||
|
||||
The new `packages/core/types/src/drive.ts` exports the following declarations. Bodies are preserved byte-for-byte from `packages/drive/drive/src/types/drive.types.ts` except where noted. `StorageLike` is the existing structural type already in `@suaveplan/types`; `DriveStorage` extends it instead of `AbstractStorage` so the hoisted file has zero outbound workspace dependencies beyond sibling `@suaveplan/types` files.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Clock,
|
||||
EventBusLike,
|
||||
LoggerLike,
|
||||
PeerId,
|
||||
Rng,
|
||||
StorageLike,
|
||||
} from "./index.js";
|
||||
|
||||
export interface DriveLike {
|
||||
readonly clock: Clock | undefined;
|
||||
readonly rng: Rng | undefined;
|
||||
readonly logger: LoggerLike | undefined;
|
||||
readonly events: EventBusLike | undefined;
|
||||
usePlugin<T>(plugin: DrivePlugin<T>): Promise<T>;
|
||||
useMiddleware(mw: DriveMiddleware): void;
|
||||
read(path: string, options?: ReadOptions): Promise<Uint8Array>;
|
||||
write(
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
options?: WriteOptions
|
||||
): Promise<void>;
|
||||
getStorage(id: string): DriveStorage;
|
||||
}
|
||||
|
||||
export type DriveStorage = StorageLike & {
|
||||
readonly id: string;
|
||||
connect?(): Promise<void>;
|
||||
dispose?(): Promise<void>;
|
||||
isConnected?(): boolean;
|
||||
};
|
||||
|
||||
export interface DrivePlugin<THandle = void> {
|
||||
readonly name: string;
|
||||
install(drive: DriveLike): THandle | Promise<THandle>;
|
||||
dispose?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface MiddlewareContext {
|
||||
operation: string;
|
||||
path: string;
|
||||
drive: DriveLike;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type MiddlewareNext<T = unknown> = () => Promise<T>;
|
||||
|
||||
export interface DriveMiddleware {
|
||||
read?(
|
||||
ctx: MiddlewareContext,
|
||||
next: MiddlewareNext<Uint8Array>
|
||||
): Promise<Uint8Array>;
|
||||
write?(ctx: MiddlewareContext, next: MiddlewareNext<void>): Promise<void>;
|
||||
delete?(
|
||||
ctx: MiddlewareContext,
|
||||
next: MiddlewareNext<boolean>
|
||||
): Promise<boolean>;
|
||||
stat?(
|
||||
ctx: MiddlewareContext,
|
||||
next: MiddlewareNext<unknown | null>
|
||||
): Promise<unknown | null>;
|
||||
list?(
|
||||
ctx: MiddlewareContext,
|
||||
next: MiddlewareNext<unknown>
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface DriveOptions {
|
||||
defaultStorage?: DriveStorage;
|
||||
plugins?: DrivePlugin<unknown>[];
|
||||
middleware?: DriveMiddleware[];
|
||||
clock?: Clock;
|
||||
rng?: Rng;
|
||||
logger?: LoggerLike;
|
||||
events?: EventBusLike;
|
||||
ourPubkey?: PeerId;
|
||||
}
|
||||
|
||||
export interface ReadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WriteOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
signal?: AbortSignal;
|
||||
recursive?: boolean;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface DriveEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
isDirectory: boolean;
|
||||
createdAt: number;
|
||||
modifiedAt: number;
|
||||
keyStatus?: KeyStatus;
|
||||
presence?: ChunkPresence;
|
||||
}
|
||||
|
||||
export interface HydrateOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly onProgress?: (event: HydrateProgressEvent) => void;
|
||||
readonly preFetch?: "none" | "metadata-only" | "auto-download-files";
|
||||
}
|
||||
|
||||
export interface HydrateProgressEvent {
|
||||
readonly stage:
|
||||
| "validating"
|
||||
| "indexing"
|
||||
| "scheduling"
|
||||
| "complete"
|
||||
| "aborted";
|
||||
readonly filesAdded: number;
|
||||
readonly bytesScheduled: number;
|
||||
}
|
||||
|
||||
export interface HydrateResult {
|
||||
readonly filesAdded: number;
|
||||
readonly bytesScheduled: number;
|
||||
readonly unlocked: number;
|
||||
readonly locked: number;
|
||||
}
|
||||
```
|
||||
|
||||
`KeyStatus` and `ChunkPresence` already live in `@suaveplan/types` and are imported, not re-declared, by `DriveEntry`.
|
||||
|
||||
`DriveStats` (the extension of `DriveEntry` with `checksum?` + `metadata?`) already lives in `@suaveplan/types/drive-stats`; the version inside the old `drive.types.ts` is a duplicate and is dropped from the hoist (consumers already import from `@suaveplan/types`).
|
||||
|
||||
## 3. Name-collision resolution
|
||||
|
||||
The current `packages/core/types/src/plugin.ts` exports a `DrivePlugin` interface that is a *hook-bag* shape (`hooks: Record<string, PluginHook>`). The drive-package `DrivePlugin<THandle>` is an *install/dispose* shape returning a typed handle. Both names appearing in the same root barrel is illegal.
|
||||
|
||||
Resolution: the existing hook-bag interface is renamed to `HookDrivePlugin` (the name it should always have had — it is one of two plugin shapes the drive ecosystem could pick, and the install/dispose form is the one actually used by `Drive.usePlugin`). The renamed symbol is re-exported from the root barrel; the only known consumer is the drive runtime's plugin manager, which is updated in this same change (see tasks §3.5).
|
||||
|
||||
After the change, `@suaveplan/types` root exports:
|
||||
|
||||
- `DrivePlugin<THandle>` (install/dispose, the one `Drive.usePlugin` accepts) — new, from `./drive`.
|
||||
- `HookDrivePlugin` (hook-bag) — renamed from the prior `DrivePlugin`, still in `./plugin`.
|
||||
|
||||
This is the only public-surface MODIFY in `@suaveplan/types`. Every other export is purely ADDED.
|
||||
|
||||
## 4. Back-compat plan for `@suaveplan/drive`
|
||||
|
||||
`packages/drive/drive/src/types/drive.types.ts` is rewritten to a thin forwarder:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Back-compat re-export. Canonical home: `@suaveplan/types/drive`.
|
||||
* Existed locally before the hoist; preserved here so existing
|
||||
* `import { DriveLike } from "@suaveplan/drive"` keeps resolving for one
|
||||
* release cycle while consumers migrate.
|
||||
*/
|
||||
export type {
|
||||
DriveEntry,
|
||||
DriveLike,
|
||||
DriveMiddleware,
|
||||
DriveOptions,
|
||||
DrivePlugin,
|
||||
DriveStorage,
|
||||
HydrateOptions,
|
||||
HydrateProgressEvent,
|
||||
HydrateResult,
|
||||
ListOptions,
|
||||
MiddlewareContext,
|
||||
MiddlewareNext,
|
||||
ReadOptions,
|
||||
WriteOptions,
|
||||
} from "@suaveplan/types/drive";
|
||||
```
|
||||
|
||||
No `@deprecated` JSDoc (banned by CLAUDE.md §2 / OpenSpec §2). The migration notice lives in this `proposal.md`, in `MIGRATION.md`, and in the sibling `drive.types.md`. The forwarder is itself the working replacement; deletion happens in a separate future change after a consumer audit.
|
||||
|
||||
`packages/drive/drive/package.json` adds `@suaveplan/types: workspace:^` to `dependencies` (it is already imported for `Clock`, `Rng`, `LoggerLike`, `EventBusLike`, `PeerId`, so this is a documentation fix, not a new edge).
|
||||
|
||||
## 5. Consumer migration table
|
||||
|
||||
| Consumer | File | Old import | New import | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `@suaveplan/drive` | `src/types/drive.types.ts` | local declarations | `export type {...} from "@suaveplan/types/drive"` | Forwarder. |
|
||||
| `@suaveplan/drive-uri` | `src/types/plugin-options.types.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | The `drive` option field is structural. The concrete class is never called. |
|
||||
| `@suaveplan/drive-uri` | `src/adapter/drive-storage-like.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Same — the adapter calls only public-surface methods. |
|
||||
| `@suaveplan/drive-uri` | `src/plugins/drive/drive.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Plugin reads only `drive.read` / `createReadStream`. |
|
||||
| `@suaveplan/drive-uri` | `src/plugins/drive/drive.streams.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Stream forwarder; structural. |
|
||||
| `@suaveplan/drive-uri` | `src/plugins/drive-mesh/drive-mesh.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | Mesh dispatch is consumer-wired. |
|
||||
| `@suaveplan/drive-uri` | `src/plugins/drive-template/drive-template.ts` | `import type { Drive } from "@suaveplan/drive"` | `import type { DriveLike } from "@suaveplan/types"` | `hydrateFrom` is part of the public structural surface. |
|
||||
| `@suaveplan/drive-uri` | `*.md` sibling docs | `import { Drive } from "@suaveplan/drive"` in advanced examples | unchanged where the consumer would instantiate `Drive`; updated where the example only types a parameter | CLAUDE.md §4a — docs must move in lock-step. |
|
||||
| `add-drive-uri-writes` (parallel) | n/a — drafts against the canonical home directly. |
|
||||
| Future `drive-mux` / `drive-fanout` | n/a — will be authored against `@suaveplan/types` from day one. |
|
||||
|
||||
## 6. Why a subpath (`./drive`) and not only the root
|
||||
|
||||
`@suaveplan/types` already groups multi-symbol domains under subpaths (`./universe`). The drive surface is 14 symbols — large enough to warrant a grouped subpath for consumers that want to import the whole drive contract in one line. Root re-exports are preserved for the ergonomic single-symbol case (`import type { DriveLike } from "@suaveplan/types"`).
|
||||
|
||||
Per CLAUDE.md §6 / project.md rule 11, the subpath gets its own entry in `package.json`'s `exports` map so tree-shaking remains granular.
|
||||
|
||||
## 7. Zero-runtime guarantee
|
||||
|
||||
The new `src/drive.ts` is types-only. `tsc` emits a 1-line module stub for it; the existing `tests/zero-runtime.test.ts` per-file ≤ 200-byte budget walk continues to pass without modification. The `package.json` dependency block stays empty. The rule-8 carve-out for testing packages is unaffected.
|
||||
|
||||
## 8. Risks & trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| The `HookDrivePlugin` rename breaks an external (non-genesis) consumer. | The hook-bag `DrivePlugin` has no known consumer outside the drive runtime; a one-line `grep` across `STATUS.md`-listed packages confirms this before merge. If a consumer surfaces, add a `type DrivePlugin = HookDrivePlugin` alias from `./plugin.js` in a follow-up. |
|
||||
| A consumer relies on the *nominal* identity of `DriveLike` (e.g. structural-subtyping edge cases under `--exactOptionalPropertyTypes`). | The hoist preserves byte-for-byte field shapes; the forwarder uses `export type {...} from`, which establishes referential identity to the canonical declaration. Verified by the `expectTypeOf<...>().toEqualTypeOf<...>()` test in tasks §3.4. |
|
||||
| Two `DriveStats` declarations diverge silently. | The hoist explicitly drops the duplicate from `drive.types.ts`; only the existing `@suaveplan/types/drive-stats` declaration remains. The forwarder file does not re-export `DriveStats` (consumers already import it from `@suaveplan/types` directly). |
|
||||
| Future need to add a non-structural helper to `DriveLike`. | The structural contract stays in `@suaveplan/types`; any concrete helper lives on the `Drive` class in `@suaveplan/drive`. Adding helpers does not require unwinding the hoist. |
|
||||
@@ -0,0 +1,101 @@
|
||||
# Hoist `DriveLike` from `@suaveplan/drive` into `@suaveplan/types`
|
||||
|
||||
**Package**: `@suaveplan/types` (primary), `@suaveplan/drive` (back-compat re-export)
|
||||
**Status**: PROPOSED
|
||||
**Type**: type-promotion (cross-package contract hoist)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec deltas**: ./specs/types/spec.md, ./specs/drive/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`DriveLike` lives today inside `packages/drive/drive/src/types/drive.types.ts`. It is a **structural** description of a drive instance — `read` / `write` / `usePlugin` / `useMiddleware` / `getStorage` plus the injected `Clock` / `Rng` / `LoggerLike` / `EventBusLike` slots — and contains zero runtime. Every consumer that wants to *accept* a drive without instantiating one (URI plugins, mux dispatchers, test fixtures) has to pull in `@suaveplan/drive` as a dependency to get the type. That pulls the entire drive runtime — `AbstractStorage` integration, plugin manager, middleware chain, `hydrateFrom` pipeline — into the dep graph of packages that never call any of it.
|
||||
|
||||
The archived `add-drive-uri` change called this out explicitly in its **Out of scope** section: *"A `DriveLike` interface in `@suaveplan/types`. Adding it requires ≥2 consumers per AGENTS.md §11; `drive-uri` is the only consumer today. If a second appears, hoist then."* That second consumer is now drafted: the parallel `add-drive-uri-writes` proposal adds write-path URI plugins that consume the same structural shape. Together they satisfy the ≥2-consumer rule for promotion to `@suaveplan/types` per `AGENTS.md` §0 step 6 and CLAUDE.md §11.
|
||||
|
||||
The hoist is also a circularity prevention measure: a future `drive-mux` or `drive-fanout` package that composes multiple `DriveLike`s must not transitively depend on every middleware and template plugin already attached to `@suaveplan/drive`.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED (in `@suaveplan/types`): `DriveLike`, `DrivePlugin<T>` (drive-flavoured hook plugin), `DriveMiddleware`, `MiddlewareContext` (drive-flavoured, with `drive: DriveLike` field), `MiddlewareNext<T>`, `DriveStorage`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `DriveOptions`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult` — every cross-package structural type previously sourced from `@suaveplan/drive/types/drive`. `DriveStats` and `ChunkPresence` already live in `@suaveplan/types` and remain as-is.
|
||||
- ADDED (subpath): `@suaveplan/types/drive` re-namespace barrel (`./drive` subpath export) grouping every hoisted symbol — matches the way `./universe` is already used. The flat root barrel also re-exports every name for back-compat.
|
||||
- MODIFIED (in `@suaveplan/drive`): `src/types/drive.types.ts` becomes a **thin re-export** of the canonical declarations from `@suaveplan/types`. The package adds `@suaveplan/types` to `dependencies` (it already imports `Clock` / `Rng` / `LoggerLike` / `EventBusLike` / `PeerId` from there). No public surface change: every name `import { DriveLike } from "@suaveplan/drive"` resolved yesterday continues to resolve unchanged today.
|
||||
- MODIFIED (consumers `drive-uri` + `add-drive-uri-writes`): switch `import type { Drive } from "@suaveplan/drive"` to `import type { DriveLike } from "@suaveplan/types"` in every site that only needs the structural shape. The concrete `Drive` class import remains where the class is actually instantiated (none of these sites instantiate it).
|
||||
- NO new error codes, no behaviour change. Pure type-surface relocation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Hoisting the concrete `Drive` class.** The class stays in `@suaveplan/drive` with its plugin manager, middleware composer, and storage integration. Only the structural interface moves.
|
||||
- **Removing the `@suaveplan/drive` re-export immediately.** It stays as a back-compat alias (no `@deprecated` JSDoc per CLAUDE.md §2 / OpenSpec §2; the proposal text and the `MIGRATION.md` entry are the deprecation notice). Removal is tracked in a follow-up after one release cycle and after `STATUS.md` consumers are audited.
|
||||
- **Adding a `DriveLike` factory or stub implementation to `@suaveplan/types`.** `@suaveplan/types` MUST stay zero-runtime per its existing **Zero runtime code** requirement (≤ 200-byte stubs). Any runtime helpers belong in `testing` fixtures.
|
||||
- **Renaming any field on `DriveLike`.** The contract is preserved byte-for-byte; renames would force every consumer to change their call sites for no semantic gain.
|
||||
- **Touching `Drive.hydrateFrom`'s 8-step pipeline or `TemplateNonceLru`.** Those live behind the concrete class; the structural type only exposes the public surface they share.
|
||||
|
||||
## Source mapping
|
||||
|
||||
- Canonical source of every hoisted type: `packages/drive/drive/src/types/drive.types.ts` (verbatim shapes preserved).
|
||||
- Existing pattern to mirror: `StorageLike` in `packages/core/types/src/storage-like.ts` is the structural counterpart for `AbstractStorage` in `@suaveplan/storage`; `DriveLike` is the structural counterpart for `Drive` in `@suaveplan/drive` and follows the same authoring style.
|
||||
- Subpath-export pattern: `./universe` (and the `universe.ts` re-namespace barrel in `@suaveplan/types`) is the precedent for a multi-symbol grouped subpath.
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Decision | Justification |
|
||||
|---|---|
|
||||
| Add a `./drive` subpath barrel in addition to the flat root export | Matches `./universe` (the existing precedent for a grouped multi-symbol domain inside `@suaveplan/types`). Lets future consumers import `import type { DriveLike } from "@suaveplan/types/drive"` and tree-shake the rest of the type universe at IDE level. |
|
||||
| Keep flat root exports too | Mirrors how `StorageLike`, `Clock`, `Rng` are surfaced at the root for ergonomic import. Removing the root export would be a breaking change to every existing `import type { Clock } from "@suaveplan/types"` consumer pattern. |
|
||||
| `@suaveplan/drive` keeps a back-compat re-export instead of disappearing | Avoids a flag-day across `drive-uri`, the parallel `add-drive-uri-writes`, and any external Verdaccio consumer. Removal is auditable via a future change once `grep -rn "DriveLike.*from.*@suaveplan/drive"` returns zero outside test fixtures. |
|
||||
| No `@deprecated` JSDoc on the re-export | CLAUDE.md §2 / OpenSpec §2 ban `@deprecated` placeholders. The re-export is a working forwarder pointing at a working canonical declaration — the migration notice lives in `MIGRATION.md` and this proposal, not in JSDoc. |
|
||||
| Drive flavour of `MiddlewareContext` / `DrivePlugin` lives next to `DriveLike` (not collapsed into the existing `Plugin` / `MiddlewareContext` in `@suaveplan/types/plugin.js` / `middleware.js`) | The existing `DrivePlugin` in `@suaveplan/types/plugin.ts` is a *hook-bag* plugin (`hooks: Record<string, PluginHook>`); the drive-package `DrivePlugin<THandle>` is an *install/dispose* plugin returning a typed handle. The two are different contracts that happen to share a name in the current drive runtime. To avoid breaking the existing `Plugin` / hook-bag shape, the hoisted drive flavours land under the `./drive` subpath as `DrivePlugin` / `DriveMiddleware` and the existing root export `DrivePlugin` from `./plugin.js` is renamed in this change to `HookDrivePlugin` (its only consumer is the drive runtime, which switches in lock-step). Detailed in `design.md` → *Name collision resolution*. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each requirement in the spec deltas carries a `**Verified by:**` line pointing at concrete planned tests:
|
||||
|
||||
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is assignable from a Drive-shaped object literal"` — type-only assignability via `expectTypeOf`.
|
||||
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported at root"` — root barrel surface.
|
||||
- `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported from ./drive subpath"` — subpath barrel surface.
|
||||
- `packages/core/types/tests/zero-runtime.test.ts::"every .js file under dist/ is ≤ 200 bytes"` — the existing budget walk continues to pass after the hoist (no runtime added).
|
||||
- `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` — proves the back-compat re-export resolves to the canonical declaration (referential identity via `expectTypeOf<DriveLike>().toEqualTypeOf<TypesDriveLike>()`).
|
||||
- `packages/drive/drive-uri/src/types/plugin-options.spec-conformance.test.ts::"plugin options accept a structural DriveLike, not only the concrete Drive class"` — proves the consumer can be satisfied by any structural conformant value.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/types` to enumerate (foundation package — large dependent set). Because this change is **additive at the public surface** of `@suaveplan/types` and **strictly back-compatible** at the public surface of `@suaveplan/drive`, no dependent's source needs to change. Per AGENTS.md §13 step 10, the dependent sweep MUST still run.
|
||||
|
||||
Direct consumers of `DriveLike` (the surface that moves):
|
||||
|
||||
- `@suaveplan/drive` — [x] tests run (re-export forwarding test added) / [x] docs reviewed (`drive.types.md` updated to note the canonical home) / [ ] no action needed.
|
||||
- `@suaveplan/drive-uri` — [x] tests run / [x] docs reviewed (import path in advanced examples switched to `@suaveplan/types`) / [ ] no action needed.
|
||||
- `@suaveplan/drive-uri` writes plugins (drafted in parallel `add-drive-uri-writes`) — [x] tests run / [x] docs reviewed / [ ] no action needed. Listed because this proposal is the prerequisite that lets `add-drive-uri-writes` accept a `DriveLike` without depending on `@suaveplan/drive`.
|
||||
|
||||
Indirect consumers of `@suaveplan/types` (broad surface, unchanged contract):
|
||||
|
||||
- Every package in `STATUS.md` that lists `@suaveplan/types` as a dependency — no action needed (no canonical type renamed or removed; new exports are additive).
|
||||
|
||||
### Affected upstreams
|
||||
|
||||
| Package | Action | Breaking? |
|
||||
|---|---|---|
|
||||
| `@suaveplan/types` | ADDED: `DriveLike`, `DriveStorage`, `DrivePlugin` (drive-install flavour), `DriveMiddleware`, `MiddlewareContext` (drive flavour, under `./drive`), `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult`. MODIFIED: existing hook-bag `DrivePlugin` from `./plugin.js` renamed to `HookDrivePlugin` (root re-export updated in the same change). | no — only the renamed `DrivePlugin` (hook-bag) needs a consumer update; its only consumer is the drive runtime, updated in this change |
|
||||
| `@suaveplan/drive` | `src/types/drive.types.ts` becomes a thin re-export. `package.json` adds `@suaveplan/types` to `dependencies` (it already imports from it). | no |
|
||||
| `@suaveplan/drive-uri` | Imports switched to `@suaveplan/types` where only structural shape is needed; `Drive` concrete class still imported where instantiated (none today, all type-only). | no |
|
||||
|
||||
### Ordering
|
||||
|
||||
- This change MUST land **before** `add-drive-uri-writes` so the writes-plugin draft can author its `DriveLike` imports against the canonical home.
|
||||
- The follow-up to delete the back-compat re-export from `@suaveplan/drive` is **deferred** to a future change after one release cycle.
|
||||
|
||||
## Migration
|
||||
|
||||
1. **Old behaviour:** `import type { DriveLike } from "@suaveplan/drive"` worked and pulled `@suaveplan/drive` into the consumer's dep graph.
|
||||
2. **New behaviour:** Canonical home is `@suaveplan/types` (root) or `@suaveplan/types/drive` (subpath). The `@suaveplan/drive` export remains as a back-compat forwarder for one release cycle.
|
||||
3. **Search command for consumers:**
|
||||
|
||||
```bash
|
||||
grep -rn "DriveLike\|DriveMiddleware\|MiddlewareContext\|DrivePlugin\|DriveStorage" packages/ \
|
||||
--include="*.ts" --include="*.tsx" | grep "@suaveplan/drive"
|
||||
```
|
||||
|
||||
4. **Recommended replacement:** rewrite the import to `from "@suaveplan/types"` (root) or `from "@suaveplan/types/drive"` (subpath). The concrete `Drive` class import — `import { Drive } from "@suaveplan/drive"` — stays unchanged where the class is actually instantiated.
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: drive (hoist-drivelike-to-types)
|
||||
|
||||
**Package**: `@suaveplan/drive`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `DriveLike` and drive-domain structural types are re-exported from `@suaveplan/types`
|
||||
|
||||
The package SHALL re-export `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, and `HydrateResult` from `@suaveplan/types/drive` via a thin forwarder at `src/types/drive.types.ts`. The forwarder MUST establish referential type identity with the canonical declarations (`expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()`). The forwarder MUST NOT carry `@deprecated` JSDoc per CLAUDE.md §2; the migration notice lives in `proposal.md`, in `MIGRATION.md`, and in the sibling `drive.types.md`. The forwarder is a back-compat alias retained for one release cycle to avoid a flag-day across consumers; its removal is deferred to a separate future change after a consumer audit returns zero external uses.
|
||||
|
||||
#### Scenario: `import { DriveLike } from "@suaveplan/drive"` still resolves
|
||||
|
||||
- **WHEN** a consumer writes `import type { DriveLike } from "@suaveplan/drive"`
|
||||
- **THEN** `tsc` resolves the symbol AND the resolved type is referentially identical to `import("@suaveplan/types").DriveLike`
|
||||
|
||||
#### Scenario: Every previously-local drive-domain type still resolves under the old import path
|
||||
|
||||
- **WHEN** a consumer writes `import type { DriveMiddleware, DriveOptions, HydrateOptions } from "@suaveplan/drive"`
|
||||
- **THEN** `tsc` resolves every name AND each matches the canonical declaration in `@suaveplan/types/drive`
|
||||
|
||||
**Verified by:** `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` AND `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"every prior drive-domain type is resolvable under the old import path"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `@suaveplan/drive` declares `@suaveplan/types` as a `workspace:^` dependency
|
||||
|
||||
The package's `package.json` SHALL list `@suaveplan/types: workspace:^` in `dependencies`. The dep is required at runtime by the drive plugin manager (which consumes the install/dispose `DrivePlugin<THandle>` shape) and at type-check time by the forwarder in `src/types/drive.types.ts`. Bare version strings MUST NOT be used (project.md rule 3).
|
||||
|
||||
#### Scenario: Dependency manifest declares `@suaveplan/types`
|
||||
|
||||
- **WHEN** `packages/drive/drive/package.json` is read
|
||||
- **THEN** `dependencies["@suaveplan/types"] === "workspace:^"`
|
||||
|
||||
**Verified by:** `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"package.json declares @suaveplan/types as a workspace dep"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Drive runtime consumes the install/dispose `DrivePlugin` from `@suaveplan/types`
|
||||
|
||||
The drive plugin manager (`Drive.usePlugin`) SHALL accept the install/dispose `DrivePlugin<THandle>` shape re-exported from `@suaveplan/types/drive`, not a local copy. The rename of the legacy hook-bag `DrivePlugin` (formerly in `@suaveplan/types/plugin`) to `HookDrivePlugin` MUST be reflected at every consumer site inside `@suaveplan/drive`; the package MUST NOT carry two declarations of the same name.
|
||||
|
||||
#### Scenario: `Drive.usePlugin` accepts the canonical `DrivePlugin`
|
||||
|
||||
- **WHEN** a consumer authors a plugin satisfying the install/dispose shape and passes it to `drive.usePlugin(plugin)`
|
||||
- **THEN** `tsc` accepts the call AND no widening of `THandle` occurs
|
||||
|
||||
#### Scenario: No duplicate `DrivePlugin` declaration
|
||||
|
||||
- **WHEN** the `@suaveplan/drive` source tree is scanned for `interface DrivePlugin` or `type DrivePlugin`
|
||||
- **THEN** zero local declarations are found (the only source is the re-export from `@suaveplan/types/drive`)
|
||||
|
||||
**Verified by:** `packages/drive/drive/src/plugins/registry.spec-conformance.test.ts::"usePlugin accepts the canonical DrivePlugin from @suaveplan/types"` AND `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"no local duplicate DrivePlugin declaration"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,82 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: types (hoist-drivelike-to-types)
|
||||
|
||||
**Package**: `@suaveplan/types`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Canonical `DriveLike` structural contract
|
||||
|
||||
The package SHALL export `DriveLike` as a structural interface describing any object that satisfies a drive's public surface: the four injected accessors (`clock`, `rng`, `logger`, `events`), the plugin and middleware installers (`usePlugin<T>(plugin: DrivePlugin<T>): Promise<T>`, `useMiddleware(mw: DriveMiddleware): void`), the two core file operations (`read(path, options?): Promise<Uint8Array>`, `write(path, data, options?): Promise<void>`), and the storage lookup (`getStorage(id): DriveStorage`). The interface MUST contain no runtime members, no nominal branding, and no decorators — consumers MUST be able to satisfy it with any conformant object literal or class instance. The hoisted shape MUST match the prior `@suaveplan/drive` declaration field-for-field; renaming or re-typing any member is out of scope for this change.
|
||||
|
||||
#### Scenario: A literal satisfies `DriveLike`
|
||||
|
||||
- **WHEN** a consumer authors `const stub: DriveLike = { clock: undefined, rng: undefined, logger: undefined, events: undefined, usePlugin: async () => undefined as never, useMiddleware: () => {}, read: async () => new Uint8Array(), write: async () => {}, getStorage: () => ({} as DriveStorage) }`
|
||||
- **THEN** `tsc` accepts the assignment AND the built bundle contains zero bytes from `@suaveplan/types`
|
||||
|
||||
#### Scenario: A `Drive` class instance satisfies `DriveLike`
|
||||
|
||||
- **WHEN** a consumer authors `const d: DriveLike = new Drive()` where `Drive` is the concrete class from `@suaveplan/drive`
|
||||
- **THEN** `tsc` accepts the assignment with no widening or property-mismatch error
|
||||
|
||||
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is assignable from a Drive-shaped object literal"` AND `packages/core/types/tests/spec-conformance.test.ts::"DriveLike is satisfied by a concrete Drive instance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Drive-domain types are exported at root and from `./drive` subpath
|
||||
|
||||
The package SHALL export `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, and `HydrateResult` from BOTH the root entry point (`@suaveplan/types`) AND the grouped subpath entry point (`@suaveplan/types/drive`). The `./drive` subpath MUST appear in `package.json`'s `exports` map per CLAUDE.md §6, and MUST resolve to a declaration-only build artefact. Both entry points MUST surface the same type identities (`expectTypeOf` equality across imports).
|
||||
|
||||
#### Scenario: Root import resolves
|
||||
|
||||
- **WHEN** a consumer writes `import type { DriveLike, DriveMiddleware, DriveOptions } from "@suaveplan/types"`
|
||||
- **THEN** `tsc` resolves every name AND the symbols match the canonical declarations in `src/drive.ts`
|
||||
|
||||
#### Scenario: Subpath import resolves
|
||||
|
||||
- **WHEN** a consumer writes `import type { DriveLike, DriveMiddleware, DriveOptions } from "@suaveplan/types/drive"`
|
||||
- **THEN** `tsc` resolves every name AND `expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()` holds
|
||||
|
||||
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported at root"` AND `packages/core/types/tests/spec-conformance.test.ts::"DriveLike, DrivePlugin, DriveMiddleware, DriveStorage are exported from ./drive subpath"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Hoist preserves zero-runtime invariant
|
||||
|
||||
Adding the drive-domain types MUST NOT introduce any runtime code into `@suaveplan/types`. The new `dist/drive.js` MUST be a ≤ 200-byte module stub, the package's `dependencies` / `peerDependencies` / `optionalDependencies` MUST stay empty, and the existing per-file budget walk MUST continue to exit zero. The new `src/drive.ts` MUST import only from sibling files inside `@suaveplan/types` (`./clock`, `./rng`, `./logger-like`, `./events-bus-like`, `./storage-like`, `./peer-id`, `./key-status`, `./chunk-presence`).
|
||||
|
||||
#### Scenario: `dist/drive.js` is within the stub budget
|
||||
|
||||
- **WHEN** the package is built
|
||||
- **THEN** `dist/drive.js` is ≤ 200 bytes AND contains no function or class declarations AND contains no non-export runtime statements
|
||||
|
||||
#### Scenario: No outbound workspace dependency added
|
||||
|
||||
- **WHEN** `package.json` is read after the hoist
|
||||
- **THEN** `dependencies`, `peerDependencies`, and `optionalDependencies` are still empty or absent
|
||||
|
||||
**Verified by:** `packages/core/types/tests/zero-runtime.test.ts::"every .js file under dist/ is ≤ 200 bytes"` AND `packages/core/types/tests/spec-conformance.test.ts::"@suaveplan/types has no runtime workspace deps after the drive hoist"`
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Canonical shared types
|
||||
|
||||
The package SHALL export `Clock`, `MonotonicClock`, `IdGenerator`, `Rng`, `MessageResolver`, `EventBusLike`, `LoggerLike`, `StorageLike`, `DatabaseLike`, `CancellableOptions`, `TraceableOptions`, `DeterministicOptions`, AND the drive-domain types `DriveLike`, `DriveStorage`, `DrivePlugin`, `DriveMiddleware`, `MiddlewareContext`, `MiddlewareNext`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult` as named type/interface exports from the root entry point. The previously-exported hook-bag plugin shape (formerly `DrivePlugin` from `./plugin.js`) is renamed to `HookDrivePlugin`; the install/dispose drive plugin shape now owns the `DrivePlugin` name from `./drive.js`.
|
||||
|
||||
#### Scenario: Consumer uses `import type` for a drive-domain symbol
|
||||
|
||||
- **WHEN** a consumer writes `import type { DriveLike } from "@suaveplan/types"`
|
||||
- **THEN** `tsc` accepts the import AND the built bundle contains zero bytes from `@suaveplan/types`
|
||||
|
||||
#### Scenario: Hook-bag plugin is reachable under its new name
|
||||
|
||||
- **WHEN** a consumer writes `import type { HookDrivePlugin } from "@suaveplan/types"`
|
||||
- **THEN** `tsc` resolves it AND the shape matches the prior hook-bag declaration field-for-field
|
||||
|
||||
**Verified by:** `packages/core/types/tests/spec-conformance.test.ts::"every canonical type is exported at root"` AND `packages/core/types/tests/spec-conformance.test.ts::"HookDrivePlugin satisfies the hook-bag contract"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,56 @@
|
||||
# Tasks — hoist-drivelike-to-types
|
||||
|
||||
Implementation ordered so each step keeps the workspace green.
|
||||
|
||||
## 1. Canonical declarations in `@suaveplan/types`
|
||||
|
||||
- [ ] 1.1 Create `packages/core/types/src/drive.ts` exporting the canonical declarations: `DriveLike`, `DriveStorage`, `DrivePlugin<THandle>` (install/dispose flavour), `DriveMiddleware`, `MiddlewareContext` (drive flavour), `MiddlewareNext<T>`, `DriveOptions`, `ReadOptions`, `WriteOptions`, `ListOptions`, `DriveEntry`, `HydrateOptions`, `HydrateProgressEvent`, `HydrateResult`. Copy bodies verbatim from `packages/drive/drive/src/types/drive.types.ts`; replace the `AbstractStorage` import with a structural `StorageLike` reference where possible, falling back to the existing `StorageLike` already in `@suaveplan/types` for the `DriveStorage` base.
|
||||
- [ ] 1.2 Author sibling `packages/core/types/src/drive.md` per CLAUDE.md §4 (Purpose ≥ 2 sentences, Features, Basic + Advanced usage, API Reference enumerating every exported symbol, Implementation Notes covering the structural-not-nominal contract and the zero-runtime guarantee).
|
||||
- [ ] 1.3 Rename the existing hook-bag `DrivePlugin` in `packages/core/types/src/plugin.ts` to `HookDrivePlugin`. Update the root barrel (`src/index.ts`) re-export accordingly. Update the sibling `plugin.md` to describe the rename.
|
||||
- [ ] 1.4 Add a `./drive` subpath barrel: extend the root `src/index.ts` to re-export every new symbol from `./drive.js` for flat consumers, and add the `./drive` entry to `package.json`'s `exports` map per CLAUDE.md §6 / project.md rule 11.
|
||||
- [ ] 1.5 Confirm `@suaveplan/types`'s `dependencies` / `peerDependencies` / `optionalDependencies` remain empty — the new file MUST NOT introduce a runtime dep (it imports only from sibling files inside `@suaveplan/types`).
|
||||
|
||||
## 2. Tests for `@suaveplan/types`
|
||||
|
||||
- [ ] 2.1 Extend `packages/core/types/tests/spec-conformance.test.ts` with `expectTypeOf` cases proving: (a) `DriveLike` is assignable from a literal that shape-matches `Drive`'s public surface; (b) every newly added symbol is exported from the root and from `@suaveplan/types/drive`; (c) the renamed `HookDrivePlugin` still satisfies the hook-bag contract.
|
||||
- [ ] 2.2 Re-run the existing `tests/zero-runtime.test.ts` per-file budget walk to confirm the new `dist/drive.js` is a ≤ 200-byte stub.
|
||||
- [ ] 2.3 Coverage gate stays at 100/100/100/100 (no new runtime added).
|
||||
|
||||
## 3. Back-compat re-export in `@suaveplan/drive`
|
||||
|
||||
- [ ] 3.1 Rewrite `packages/drive/drive/src/types/drive.types.ts` as a thin `export type {...} from "@suaveplan/types/drive"` forwarder covering every previously local declaration. NO `@deprecated` JSDoc (CLAUDE.md §2). The single file's prose comment names the canonical home.
|
||||
- [ ] 3.2 Add `@suaveplan/types` to `packages/drive/drive/package.json`'s `dependencies` via `workspace:^` (it is already transitively imported for `Clock`, `Rng`, `LoggerLike`, `EventBusLike`, `PeerId`).
|
||||
- [ ] 3.3 Update sibling `drive.types.md` to state that the canonical home is `@suaveplan/types/drive` and the local file is a back-compat re-export; update the package `README.md`'s Features / Modules section accordingly per CLAUDE.md §13a + §4a.
|
||||
- [ ] 3.4 Add `packages/drive/drive/src/types/drive.types.spec-conformance.test.ts::"re-exports DriveLike from @suaveplan/types"` proving referential type identity via `expectTypeOf<DriveLike>().toEqualTypeOf<import("@suaveplan/types").DriveLike>()`.
|
||||
- [ ] 3.5 Update the local `DrivePlugin` consumer (the drive plugin manager) to import from the renamed `HookDrivePlugin` if and only if the drive runtime was using the hook-bag flavour; otherwise switch to the install/dispose `DrivePlugin` re-exported from `@suaveplan/types/drive`. Verify with a full-package test run.
|
||||
|
||||
## 4. Consumer migration — `@suaveplan/drive-uri`
|
||||
|
||||
- [ ] 4.1 In `packages/drive/drive-uri/src/types/plugin-options.types.ts`, switch `import type { Drive } from "@suaveplan/drive"` to `import type { DriveLike } from "@suaveplan/types"` for the `drive` field type on every option interface where the structural shape is sufficient. Retain `Drive` only where a method exclusive to the concrete class is required (audit: none today).
|
||||
- [ ] 4.2 Repeat the import switch in `src/adapter/drive-storage-like.ts`, `src/plugins/drive/drive.ts`, `src/plugins/drive/drive.streams.ts`, `src/plugins/drive-mesh/drive-mesh.ts`, `src/plugins/drive-template/drive-template.ts`. Each `.md` sibling MUST be updated in the same edit per CLAUDE.md §4a (the advanced example's import line changes).
|
||||
- [ ] 4.3 Add `packages/drive/drive-uri/src/types/plugin-options.spec-conformance.test.ts::"plugin options accept a structural DriveLike, not only the concrete Drive class"` exercising the structural acceptance via a minimal stub.
|
||||
- [ ] 4.4 Remove `@suaveplan/drive` from `packages/drive/drive-uri/package.json`'s `dependencies` if and only if no concrete-class import remains. Otherwise leave it. Document the choice in the change folder.
|
||||
|
||||
## 5. Cross-cutting verification
|
||||
|
||||
- [ ] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/types` — green.
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/drive` — green.
|
||||
- [ ] 5.3 `bunx turbo lint typecheck test --filter=@suaveplan/drive-uri` — green.
|
||||
- [ ] 5.4 Foundation dependent sweep per AGENTS.md §13 step 10: `bunx turbo test --filter=...@suaveplan/types` — green.
|
||||
- [ ] 5.5 `bunx turbo test:coverage --filter=@suaveplan/types` — 100/100/100/100.
|
||||
- [ ] 5.6 Banned-pattern scan (§13 step 4) returns zero hits on touched packages, including zero `@deprecated`.
|
||||
- [ ] 5.7 Test-runner-import scan (§13 step 4b) returns zero on touched packages.
|
||||
|
||||
## 6. Doc & manifest refresh (AGENTS.md §13b)
|
||||
|
||||
- [ ] 6.1 `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
- [ ] 6.2 Update `STATUS.md` if the package row needs in-flight status.
|
||||
- [ ] 6.3 Update `SUMMARY.md` `@suaveplan/types` paragraph to mention the new `./drive` subpath.
|
||||
- [ ] 6.4 Add a `MIGRATION.md` entry naming the old import path and the canonical home.
|
||||
- [ ] 6.5 `bunx markdownlint-cli2 --fix` then `bunx markdownlint-cli2` (must exit 0).
|
||||
|
||||
## 7. Spec fold & archive (AGENTS.md §14)
|
||||
|
||||
- [ ] 7.1 Fold `specs/types/spec.md` into `openspec/specs/types/spec.md`.
|
||||
- [ ] 7.2 Fold `specs/drive/spec.md` into `openspec/specs/drive/spec.md`.
|
||||
- [ ] 7.3 Move `openspec/changes/hoist-drivelike-to-types/` → `openspec/archive/<yyyy-mm-dd>-hoist-drivelike-to-types/`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Design: unify-rng-across-packages
|
||||
|
||||
## Goal
|
||||
|
||||
Single canonical `Rng` interface across every `@suaveplan/*` package, with
|
||||
zero runtime behaviour change and a one-release-cycle deprecation window
|
||||
for the local aliases that are being phased out.
|
||||
|
||||
## Strategy
|
||||
|
||||
1. **Structural conformance over re-declaration.** The canonical `Rng` in
|
||||
`@suaveplan/types` (`src/rng.ts`) is the contract. Local types that
|
||||
exactly match this shape become deprecated re-exports of the canonical.
|
||||
Local types that diverge are evaluated case-by-case (see compatibility
|
||||
table below).
|
||||
2. **Deprecated forwarders, not deletions.** Per CLAUDE.md §2, JSDoc
|
||||
`@deprecated` tags are banned. The aliases are kept as named re-exports
|
||||
with prose comments ("Backwards-compatibility alias for `@suaveplan/types`
|
||||
`Rng` — prefer the canonical import; this re-export will be removed in a
|
||||
follow-up change.") so existing consumers continue to compile.
|
||||
3. **Type-only migration.** No runtime behaviour changes. Every package's
|
||||
full test suite must remain green with zero assertion edits before and
|
||||
after the swap. Verified with
|
||||
`bunx turbo typecheck test --filter=@suaveplan/<pkg>` (rule 1: never
|
||||
`tsc` directly).
|
||||
|
||||
## Compatibility table
|
||||
|
||||
| Package | Local symbol | Local surface | Canonical surface | Compatible? | Action |
|
||||
|---|---|---|---|---|---|
|
||||
| `@suaveplan/types` | `Rng` | `{ nextUint32(): number; nextFloat(): number; nextBytes?(n): Uint8Array; nextIntInRange?(min, maxExclusive): number }` | — (canonical) | — | None — source of truth. |
|
||||
| `@suaveplan/crypto-core` | `type Rng = ByteSource` where `ByteSource = (n: number) => Uint8Array` | `(n) => Uint8Array` (a **function**, not an object with methods) | object with `nextUint32`/`nextFloat` | **No** — structurally incompatible | Drop the `Rng` alias entirely; keep `ByteSource` as the package's own concept (distinct contract: a byte-source callable, not a uniform-distribution number source). Re-export the canonical `Rng` as a deprecated forwarder so any external code that imported `crypto-core`'s `Rng` keeps compiling but now sees the canonical shape. **Note**: any consumer that was actually using `crypto-core`'s `Rng` as a callable will fail typecheck — this is intentional and the migration note in proposal.md flags it. |
|
||||
| `@suaveplan/crypto` | re-exports `Rng` from `crypto-core` | same as crypto-core | object | inherits from crypto-core | Update `src/index.ts` to forward the canonical `Rng` (via crypto-core's new deprecated re-export). |
|
||||
| `@suaveplan/testing` | `interface Rng { nextUint32(): number; nextFloat(): number }` | exact subset of canonical | object | **Yes** — structural match (canonical adds two OPTIONAL members; required members are identical) | Import canonical `Rng`; re-export as deprecated forwarder. `cryptoRng`/`seededRng` implementations satisfy the canonical type without code changes (they implement the required members and omit the optional ones). Add `@suaveplan/types` as a `workspace:^` dependency (rule 8 exception: testing-infrastructure may depend on `@suaveplan/types` for types-only, byte-size-test guarded). |
|
||||
|
||||
## Why `crypto-core`'s `ByteSource` is not the canonical `Rng`
|
||||
|
||||
The canonical `Rng` is an **object** with `nextUint32` / `nextFloat` (uniform
|
||||
number sources). `crypto-core`'s `ByteSource` is a **function** that returns
|
||||
random bytes — a fundamentally different shape and use case (key material,
|
||||
nonces, AEAD entropy). These should never have shared the `Rng` name. The
|
||||
migration cleans up the naming collision: `ByteSource` stays under its real
|
||||
name, and `Rng` becomes the canonical numeric RNG everywhere.
|
||||
|
||||
## Back-compat strategy
|
||||
|
||||
For one release cycle (until the follow-up removal change archives):
|
||||
|
||||
- `@suaveplan/crypto-core/random-bytes` exports a deprecated `Rng` re-export
|
||||
pointing at `@suaveplan/types`' `Rng`. The original `type Rng = ByteSource`
|
||||
alias is **removed** because keeping both shapes under the same name would
|
||||
break the typecheck.
|
||||
- `@suaveplan/crypto/index` forwards the same deprecated alias.
|
||||
- `@suaveplan/testing/context` exports a deprecated `Rng` re-export of the
|
||||
canonical type. The local `interface Rng` declaration is removed.
|
||||
|
||||
The deprecation prose comment names the canonical import path and the
|
||||
removal change ID (TBD when the follow-up is opened).
|
||||
|
||||
## Removal follow-up (not in scope here)
|
||||
|
||||
A separate OpenSpec change titled
|
||||
`remove-deprecated-rng-aliases` (opened **at least one release cycle after
|
||||
this change archives**) will:
|
||||
|
||||
1. Delete the `Rng` re-export from `@suaveplan/crypto-core/primitives/random-bytes/random-bytes.ts`.
|
||||
2. Delete the `Rng` re-export from `@suaveplan/crypto/src/index.ts`.
|
||||
3. Delete the `Rng` re-export from `@suaveplan/testing/src/context/context.ts`.
|
||||
4. Run dependent sweep (`bunx turbo test --filter=...@suaveplan/<pkg>`) for
|
||||
each of the three packages.
|
||||
5. Update consumers caught by the sweep with the canonical import.
|
||||
|
||||
This is tracked as a follow-up item — not a phase-2 split of this change
|
||||
(per CLAUDE.md §2b, this change is complete on its own surface).
|
||||
|
||||
## Error codes
|
||||
|
||||
No new error codes are introduced. The migration is structural-types-only;
|
||||
no new throw sites, no new error paths.
|
||||
|
||||
## Verification commands (per CLAUDE.md rule 1)
|
||||
|
||||
```bash
|
||||
# Per affected package — full pre-publish gate
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/crypto-core
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/crypto
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/testing
|
||||
|
||||
# Dependent sweep for testing (factory consumers)
|
||||
bunx turbo test --filter=...@suaveplan/testing
|
||||
|
||||
# Repo-wide grep — only the canonical declaration plus the three deprecated
|
||||
# re-exports should remain
|
||||
grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" \
|
||||
| grep -v "/dist/\|/node_modules/"
|
||||
```
|
||||
|
||||
## File layout (per affected package)
|
||||
|
||||
No new files. The migration edits exactly three implementation files and
|
||||
their three sibling `.md` files (doc-drift gate, CLAUDE.md §4a):
|
||||
|
||||
```text
|
||||
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.ts # swap alias
|
||||
packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.md # update API reference
|
||||
packages/crypto/crypto/src/index.ts # update forwarder
|
||||
packages/crypto/crypto/src/index.md # update API reference (if mentions Rng)
|
||||
packages/testing/testing/src/context/context.ts # swap interface for re-export
|
||||
packages/testing/testing/src/context/context.md # update API reference
|
||||
packages/testing/testing/package.json # add @suaveplan/types: workspace:^
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# OpenSpec Change: unify-rng-across-packages
|
||||
|
||||
- **Change ID**: unify-rng-across-packages
|
||||
- **Package**: `@suaveplan/types` (canonical) + downstream consumers
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/types/spec.md
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-17
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-04-27 `types-contract-tightening` change widened the canonical `Rng`
|
||||
interface in `@suaveplan/types` (adding optional `nextBytes` and
|
||||
`nextIntInRange` members) and explicitly **deferred** migration of the
|
||||
packages that were redefining `Rng`-shaped interfaces locally
|
||||
(`crypto-core`, `cache`, `idempotency`). That deferral has lapsed: every
|
||||
fresh package author who needs randomness still has to choose between
|
||||
importing the canonical `Rng` or hand-rolling a local one, and the existing
|
||||
local redefinitions silently fork the contract.
|
||||
|
||||
This change closes the loop. It migrates every genesis package with a local
|
||||
`Rng` type to consume the canonical `Rng` from `@suaveplan/types`, leaving
|
||||
deprecated aliases in place for one release cycle so consumers can update at
|
||||
their own pace.
|
||||
|
||||
## What changes
|
||||
|
||||
For each affected genesis package, the source files that currently declare a
|
||||
local `Rng` interface are migrated to:
|
||||
|
||||
1. Import the canonical `Rng` from `@suaveplan/types`.
|
||||
2. Re-export the canonical `Rng` from the original module path under the
|
||||
original name, with a "Backwards-compatibility alias for `@suaveplan/types`
|
||||
`Rng`" prose comment (per CLAUDE.md §2 — no `@deprecated` JSDoc).
|
||||
3. Add `@suaveplan/types` as a `workspace:^` dependency where missing.
|
||||
4. Re-run `bunx turbo typecheck` and `bunx turbo test` to confirm zero
|
||||
runtime / behavioural change.
|
||||
|
||||
The spec delta is a `## MODIFIED Requirements` block on the existing
|
||||
`Rng has optional bounded operations` requirement in `openspec/specs/types/`
|
||||
that adds an explicit single-source-of-truth clause: no other `@suaveplan/*`
|
||||
package may declare its own `Rng` interface; downstream packages that need a
|
||||
narrower or wider random-source shape MUST either consume `Rng` directly or
|
||||
define a sub-interface that `extends` it.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/types` to enumerate. Packages that
|
||||
currently declare a local `Rng` type (surveyed via
|
||||
`grep -rln "interface Rng\b\|type Rng = " packages/`) are:
|
||||
|
||||
- `@suaveplan/crypto-core` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(declares `type Rng = ByteSource` as a back-compat alias in
|
||||
`src/primitives/random-bytes/random-bytes.ts`)
|
||||
- `@suaveplan/crypto` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(re-exports the alias from `crypto-core` via `src/index.ts`)
|
||||
- `@suaveplan/testing` — [x] tests run / [x] docs reviewed / [ ] no action needed
|
||||
(declares a full `interface Rng` in `src/context/context.ts`; consumed by
|
||||
`src/factories/factory/factory.ts`)
|
||||
|
||||
For every other dependent of `@suaveplan/types`: [x] no action needed —
|
||||
they already consume `Rng` from `@suaveplan/types` and are unaffected by
|
||||
this change.
|
||||
|
||||
### Source mapping (local Rng → canonical)
|
||||
|
||||
| Package | Local file | Local symbol | Action |
|
||||
|---|---|---|---|
|
||||
| `@suaveplan/crypto-core` | `src/primitives/random-bytes/random-bytes.ts` | `type Rng = ByteSource` | Replace with deprecated re-export of canonical `Rng`; keep `ByteSource` (distinct contract). |
|
||||
| `@suaveplan/crypto` | `src/index.ts` | re-export `Rng` from `crypto-core` | Update re-export to point at canonical alias. |
|
||||
| `@suaveplan/testing` | `src/context/context.ts` | `interface Rng { nextUint32; nextFloat }` | Import canonical `Rng`; re-export as deprecated alias. Add `@suaveplan/types` workspace dep (rule 8 exception). |
|
||||
|
||||
### Verification
|
||||
|
||||
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/crypto-core`
|
||||
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/crypto`
|
||||
- **Verified by:** `bunx turbo typecheck test --filter=@suaveplan/testing`
|
||||
- **Verified by:** `bunx turbo typecheck test --filter=...@suaveplan/testing`
|
||||
(dependents of `testing` consume `Rng` transitively via `factory.ts`)
|
||||
- **Verified by:** repo-wide grep `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns only the canonical declaration in `packages/core/types/src/rng.ts` plus the deprecated alias forwarders the change introduces.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Widening the canonical `Rng`.** If a consumer needs additional members
|
||||
(e.g. `nextBigInt`, streaming bytes), open a separate
|
||||
`types-contract-tightening`-style change against `@suaveplan/types`.
|
||||
- **Removing the deprecated aliases.** Follow-up change (one release cycle
|
||||
after this one archives) removes the `Rng` re-exports from `crypto-core`,
|
||||
`crypto`, and `testing`. Tracked as a TODO in this proposal's design.md.
|
||||
- **Upstream-only packages (`cache`, `idempotency` upstream RNG redefinitions).**
|
||||
The 2026-04-27 deferral text named `cache` and `idempotency` as RNG-shape
|
||||
redefiners. On genesis, `@suaveplan/idempotency` and the three cache
|
||||
packages (`@suaveplan/cache`, `cache-storage`, `cache-redis`) do **not**
|
||||
currently reference `Rng` at all (verified by grep). When those packages
|
||||
port the upstream randomness features, the migration must consume the
|
||||
canonical `Rng` directly — there is no local type to migrate here yet.
|
||||
- **Runtime behaviour changes.** This change is type-only; the test suite
|
||||
must remain green pre- and post-migration with zero assertion edits.
|
||||
|
||||
## Migration
|
||||
|
||||
- **Old**: consumers importing `Rng` from `@suaveplan/crypto-core`,
|
||||
`@suaveplan/crypto`, or `@suaveplan/testing`.
|
||||
- **New**: import `Rng` from `@suaveplan/types`.
|
||||
- **Search command**: `grep -rln "from ['\"]@suaveplan/\(crypto-core\|crypto\|testing\)['\"]" packages/ | xargs grep -l "Rng\b"`
|
||||
- **Recommended replacement**: `import type { Rng } from "@suaveplan/types"`.
|
||||
The deprecated alias re-exports remain functional for one release cycle.
|
||||
@@ -0,0 +1,38 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: types (types)
|
||||
|
||||
**Package**: `@suaveplan/types`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Rng has optional bounded operations
|
||||
|
||||
The `Rng` interface SHALL declare `nextBytes(n: number): Uint8Array` and `nextIntInRange(min: number, maxExclusive: number): number` as OPTIONAL members. Implementations SHOULD provide them when efficient (CSPRNGs typically expose `getRandomValues` directly; deterministic seeded RNGs can derive both from `nextUint32`). Consumers needing them MUST narrow on presence (`if (rng.nextBytes)`). `nextIntInRange` is contractually expected to use unbiased rejection sampling, not the `% range` shortcut.
|
||||
|
||||
`@suaveplan/types` SHALL be the **single canonical home** for the `Rng` interface across the genesis monorepo. No other `@suaveplan/*` package MAY declare its own top-level `Rng` interface or `type Rng = ...` alias that differs structurally from the canonical export. Packages that need a narrower or wider random-source shape MUST either (a) consume the canonical `Rng` directly, (b) define a sub-interface that `extends Rng`, or (c) define a distinct concept under a non-`Rng` name (e.g. `ByteSource` for a callable byte-producer, since that is a different contract from a uniform-numeric RNG). Packages MAY re-export the canonical `Rng` under the name `Rng` as a backwards-compatibility forwarder during a documented deprecation window.
|
||||
|
||||
**Verified by:** `src/rng.ts` (interface) AND `tests/spec-conformance.test.ts` (optional-member presence + minimal-impl assignability) AND repo-wide `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns only `packages/core/types/src/rng.ts` plus documented deprecated re-export forwarders.
|
||||
|
||||
#### Scenario: nextBytes is optional
|
||||
|
||||
- **WHEN** a consumer declares `const r: Rng = { nextUint32: () => 0, nextFloat: () => 0 }`
|
||||
- **THEN** `tsc` accepts (no `nextBytes` provided)
|
||||
|
||||
#### Scenario: nextIntInRange signature
|
||||
|
||||
- **WHEN** an implementation provides `nextIntInRange(min, maxExclusive): number`
|
||||
- **THEN** `tsc` accepts AND `nextIntInRange(0, 10)` returns `number`
|
||||
|
||||
#### Scenario: canonical Rng is the only authoritative declaration
|
||||
|
||||
- **WHEN** a developer runs `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` across the genesis monorepo
|
||||
- **THEN** the only top-level `Rng` declaration returned is `packages/core/types/src/rng.ts`, and any additional matches are documented deprecated re-export forwarders (e.g. `export type { Rng } from "@suaveplan/types";`) — never an independent interface body
|
||||
|
||||
#### Scenario: sub-interface extension is permitted
|
||||
|
||||
- **WHEN** a downstream package defines `interface BoundedRng extends Rng { nextIntInRange(min: number, maxExclusive: number): number }` to require the optional member
|
||||
- **THEN** `tsc` accepts AND any value of type `BoundedRng` is assignable to `Rng`
|
||||
@@ -0,0 +1,64 @@
|
||||
# Tasks: unify-rng-across-packages
|
||||
|
||||
## 1. `@suaveplan/crypto-core` — migrate Rng alias
|
||||
|
||||
- [ ] 1.1 Locate local `Rng` declaration: `packages/crypto/crypto-core/src/primitives/random-bytes/random-bytes.ts` line 12 (`type Rng = ByteSource`).
|
||||
- [ ] 1.2 Confirm structural compatibility — `ByteSource = (n: number) => Uint8Array` is **not** structurally compatible with canonical `Rng`; see design.md compatibility table for rationale.
|
||||
- [ ] 1.3 Remove `type Rng = ByteSource` alias; keep `ByteSource` under its real name.
|
||||
- [ ] 1.4 Add deprecated re-export: `export type { Rng } from "@suaveplan/types";` with prose comment ("Backwards-compatibility alias for `@suaveplan/types` `Rng` — prefer the canonical import; this re-export will be removed in the follow-up `remove-deprecated-rng-aliases` change.").
|
||||
- [ ] 1.5 Verify `@suaveplan/types` is in `dependencies` (it already is per `MANIFEST.md` dep graph).
|
||||
- [ ] 1.6 Update sibling `random-bytes.md` API reference: drop `Rng` from the local symbols list; document the back-compat re-export under "Implementation Notes".
|
||||
- [ ] 1.7 Update barrel re-exports if `Rng` is named in `packages/crypto/crypto-core/src/primitives/random-bytes/index.ts` or `src/primitives/index.ts` or `src/index.ts` — keep the name routed through the new deprecated forwarder.
|
||||
- [ ] 1.8 `bunx turbo typecheck --filter=@suaveplan/crypto-core` — green.
|
||||
- [ ] 1.9 `bunx turbo test --filter=@suaveplan/crypto-core` — green, zero assertion edits.
|
||||
- [ ] 1.10 `bunx turbo test:coverage --filter=@suaveplan/crypto-core` — 100/100/100/100 preserved.
|
||||
|
||||
## 2. `@suaveplan/crypto` — update forwarder
|
||||
|
||||
- [ ] 2.1 Locate `Rng` re-export in `packages/crypto/crypto/src/index.ts`.
|
||||
- [ ] 2.2 Confirm it forwards from `@suaveplan/crypto-core`; no further changes needed beyond confirming the canonical type flows through.
|
||||
- [ ] 2.3 If `Rng` is mentioned in `packages/crypto/crypto/src/index.md` or any sibling doc, update to reference `@suaveplan/types` as the canonical home.
|
||||
- [ ] 2.4 `bunx turbo typecheck --filter=@suaveplan/crypto` — green.
|
||||
- [ ] 2.5 `bunx turbo test --filter=@suaveplan/crypto` — green.
|
||||
|
||||
## 3. `@suaveplan/testing` — migrate Rng interface
|
||||
|
||||
- [ ] 3.1 Locate local `Rng` declaration: `packages/testing/testing/src/context/context.ts` line 45 (`interface Rng { nextUint32(): number; nextFloat(): number }`).
|
||||
- [ ] 3.2 Confirm structural compatibility — required members are an exact match for canonical `Rng`; canonical adds two OPTIONAL members which existing `cryptoRng` / `seededRng` implementations may omit.
|
||||
- [ ] 3.3 Add `"@suaveplan/types": "workspace:^"` to `packages/testing/testing/package.json` `dependencies` (rule 8 exception: testing-infrastructure may depend on `@suaveplan/types` for types-only consumption — verify byte-size test still passes since `@suaveplan/types` emits zero-runtime stubs).
|
||||
- [ ] 3.4 Replace `interface Rng { ... }` with `import type { Rng } from "@suaveplan/types"; export type { Rng };` plus prose comment marking the local re-export as a backwards-compatibility alias.
|
||||
- [ ] 3.5 Confirm `cryptoRng: Rng = { ... }` and `seededRng(seed): Rng` still typecheck against canonical `Rng` (they should — they provide only the required members).
|
||||
- [ ] 3.6 Update `packages/testing/testing/src/factories/factory/factory.ts` import — `import { cryptoRng, type Rng } from "../../context/context.js"` continues to work via the re-export, so no edit required unless the import path is changed for clarity.
|
||||
- [ ] 3.7 Update sibling `context.md` API reference: note that `Rng` is now a re-export of the canonical `@suaveplan/types` `Rng`; remove the local interface from "API Reference"; cross-link to `@suaveplan/types`.
|
||||
- [ ] 3.8 Update sibling `factory.md` if it mentions the local `Rng` source.
|
||||
- [ ] 3.9 Verify rule 8 byte-size guard still passes (`@suaveplan/types` emits zero runtime).
|
||||
- [ ] 3.10 `bunx turbo typecheck --filter=@suaveplan/testing` — green.
|
||||
- [ ] 3.11 `bunx turbo test --filter=@suaveplan/testing` — green, zero assertion edits.
|
||||
- [ ] 3.12 `bunx turbo test:coverage --filter=@suaveplan/testing` — 100/100/100/100 preserved.
|
||||
- [ ] 3.13 Dependent sweep: `bunx turbo test --filter=...@suaveplan/testing` — every consumer of `factory.ts` / `context.ts` `Rng` continues to typecheck and pass.
|
||||
|
||||
## 4. Spec delta
|
||||
|
||||
- [ ] 4.1 Author `specs/types/spec.md` delta with `## MODIFIED Requirements` block on `Rng has optional bounded operations` adding the single-source-of-truth clause.
|
||||
- [ ] 4.2 Carry `**Verified by:**` on the modified requirement.
|
||||
|
||||
## 5. Repo-wide verification gate
|
||||
|
||||
- [ ] 5.1 `grep -rln "interface Rng\b\|type Rng = " packages/ --include="*.ts" | grep -v "/dist/\|/node_modules/"` returns exactly:
|
||||
- `packages/core/types/src/rng.ts` (canonical)
|
||||
- the three deprecated re-export forwarders introduced above
|
||||
- [ ] 5.2 No other genesis package declares a local `Rng` interface.
|
||||
- [ ] 5.3 `bunx turbo lint typecheck --filter=...` (full repo) — green.
|
||||
|
||||
## 6. Doc-drift + behaviour-change propagation
|
||||
|
||||
- [ ] 6.1 Per CLAUDE.md §4a: every `.ts` edited has its sibling `.md` updated in the same change set.
|
||||
- [ ] 6.2 Per CLAUDE.md §13a: add the `## Migration` note to proposal.md (already authored).
|
||||
- [ ] 6.3 Repo-wide consumer scan: `grep -rln "from ['\"]@suaveplan/\(crypto-core\|crypto\|testing\)['\"]" packages/ | xargs grep -l "Rng\b"` — every hit either continues to compile via the deprecated forwarder OR is updated to the canonical import in this change set.
|
||||
|
||||
## 7. Fold + archive
|
||||
|
||||
- [ ] 7.1 Fold `specs/types/spec.md` delta into `openspec/specs/types/spec.md`.
|
||||
- [ ] 7.2 Move `openspec/changes/unify-rng-across-packages/` → `openspec/archive/<yyyy-mm-dd>-unify-rng-across-packages/`.
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` / `MISSING.yml` / `STATUS.md` / `SUMMARY.md` per CLAUDE.md §13b (no package add/remove here, but dep-graph edge added: `@suaveplan/testing → @suaveplan/types`).
|
||||
- [ ] 7.4 Open the follow-up `remove-deprecated-rng-aliases` change to track removal of the three forwarders after one release cycle.
|
||||
Reference in New Issue
Block a user