Files
suaveplan/openspec/specs/storage-browser-localstorage/spec.md
2026-05-27 23:29:53 +01:00

174 lines
12 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: storage-browser-localstorage (storage-browser-localstorage)
**Package**: `@suaveplan/storage-browser-localstorage`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: AbstractStorage extension over the synchronous Storage API
The package SHALL ship a `LocalStorageStorage` class that extends `AbstractStorage` from `@suaveplan/storage/abstract` and presents the synchronous DOM `Storage` API (`getItem`/`setItem`/`removeItem`/`clear`/`length`/`key(i)`) through the AbstractStorage async surface. All abstract methods (`get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`) MUST be implemented; backends without native streaming or partial IO MUST fall back to the AbstractStorage reference helpers.
#### Scenario: round-trips Uint8Array values
- **WHEN** the caller invokes `set("k", bytes)` followed by `get("k")`
- **THEN** the returned `Uint8Array` is byte-equal to the original input.
#### Scenario: capabilities matrix matches the documented baseline
- **WHEN** the caller reads `storage.capabilities`
- **THEN** `partialReads === false`, `partialWrites === false`, `nativeStreams === false`, `atomicWrites === true`, `contentAddressable === false`.
**Verified by:** `src/local-storage/local-storage.test.ts::"round-trips Uint8Array values"` AND `src/local-storage/local-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"` AND `src/local-storage/local-storage.spec-conformance.test.ts::"implements every AbstractStorage abstract method"`
---
### Requirement: Base64-encoded JSON envelope wire format
The package SHALL serialise stored values under each key as a JSON envelope `{ v: 1, payload: <base64>, meta: { size, createdAt, modifiedAt, mimeType?, custom? }, expiresAt? }`. The `payload` field MUST be the standard base64 encoding of the binary `Uint8Array` value. Malformed envelopes (invalid JSON, malformed base64, missing required fields) MUST raise `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` from the typed boundary.
#### Scenario: envelope round-trips arbitrary bytes
- **WHEN** the caller stores `Uint8Array([0, 1, 255, 128])` under key `k`
- **THEN** the underlying `localStorage` value for the namespaced key is a JSON string with a base64 `payload` field that decodes back to the original bytes.
#### Scenario: corrupt envelope raises CORRUPT_PAYLOAD
- **WHEN** the underlying `localStorage` slot for a key is manually overwritten with non-JSON garbage
- **THEN** `get("k")` raises `LocalStorageError` with code `LOCAL_STORAGE.CORRUPT_PAYLOAD`.
**Verified by:** `src/envelope/envelope.test.ts::"round-trips arbitrary bytes through encodeEnvelope/decodeEnvelope"` AND `src/envelope/envelope.test.ts::"decodeEnvelope raises CORRUPT_PAYLOAD on invalid JSON"` AND `src/local-storage/local-storage.test.ts::"raises CORRUPT_PAYLOAD when the slot contains non-envelope JSON"`
---
### Requirement: Namespaced keyspace and prefix-based listing with pagination
The package SHALL store every entry under a namespaced wire key of the form `${namespace}:${key}` and SHALL provide `list(options?)` that iterates every `localStorage.key(i)` slot, filters to the configured namespace, strips the prefix, applies optional `prefix`/`offset`/`limit`, and returns `{ keys, total, hasMore }`. Entries belonging to other tools in the same origin MUST be ignored.
#### Scenario: list returns keys with pagination + prefix
- **GIVEN** entries `a`, `b`, `c`, `user/1`, `user/2`
- **WHEN** `list({ prefix: "user/" })` is called
- **THEN** the returned `keys` is exactly `["user/1", "user/2"]`.
#### Scenario: foreign localStorage keys are ignored
- **GIVEN** a sibling tool wrote `other-app-key` directly to `localStorage`
- **WHEN** `clear()` is called
- **THEN** only the package's namespaced entries are removed; `other-app-key` is left intact.
**Verified by:** `src/local-storage/local-storage.test.ts::"list returns keys with pagination + prefix"` AND `src/local-storage/local-storage.test.ts::"clear does not touch foreign localStorage entries"`
---
### Requirement: Lazy TTL expiry on read
The package SHALL accept an optional `ttl` (seconds) on `set` and SHALL stamp `expiresAt = clock() + ttl * 1000` into the envelope. On every `get`/`exists`/`getMetadata`/`getWithMetadata` call the implementation MUST check `expiresAt` against the injected `Clock` and, when expired, MUST remove the slot from the underlying storage AND return as if the entry did not exist (`undefined` / `null` / `false`). Expired rows MUST be excluded from `list()`.
#### Scenario: expired entry returns undefined and is removed from storage
- **GIVEN** an entry written with `ttl: 1`
- **WHEN** the injected clock advances past the expiry
- **THEN** `get` returns `undefined`, `exists` returns `false`, the underlying `localStorage` slot is gone.
**Verified by:** `src/local-storage/local-storage.test.ts::"expired entries return undefined and are deleted on read"` AND `src/local-storage/local-storage.test.ts::"list excludes expired entries"`
---
### Requirement: Strict key sanitisation boundary
The package SHALL expose a `sanitizeKey(key)` function that rejects non-string input, the empty string, keys longer than 256 chars, keys containing control characters or null bytes, absolute POSIX paths, and `..` traversal segments. Every public method MUST funnel caller-supplied keys through `sanitizeKey` before any `localStorage` API call. Rejections MUST throw `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` (or `LOCAL_STORAGE.KEY_TOO_LONG` for the length cap).
#### Scenario: path-traversal attempt rejects before any localStorage call
- **WHEN** the caller invokes `storage.get("../../etc/passwd")`
- **THEN** the call throws `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` AND the underlying `Storage.getItem` is NEVER called.
**Verified by:** `src/key-sanitizer/key-sanitizer.test.ts::"rejects path traversal segments"` AND `src/local-storage/local-storage.test.ts::"path-traversal attempts reject before any Storage call"`
---
### Requirement: AbortSignal threading
The package SHALL accept an optional `AbortSignal` on every public async method. When the signal is already aborted at call time, the method MUST throw `LocalStorageError(LOCAL_STORAGE.ABORTED)` BEFORE touching the underlying `Storage` adapter.
#### Scenario: pre-aborted signal throws ABORTED
- **GIVEN** an `AbortController` whose signal has already been aborted
- **WHEN** any public method is called with that signal
- **THEN** the method rejects with `LocalStorageError(LOCAL_STORAGE.ABORTED)`.
**Verified by:** `src/local-storage/local-storage.test.ts::"rejects with LOCAL_STORAGE.ABORTED when signal is pre-aborted"`
---
### Requirement: Typed error registry covering every observable failure
The package SHALL define a frozen `LOCAL_STORAGE_CODES` registry via `defineCodes("LOCAL_STORAGE", […])` containing `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `CORRUPT_PAYLOAD`. Quota errors MUST map `DOMException` with `name === "QuotaExceededError"` OR legacy `code === 22` OR Firefox `code === 1014` to `LOCAL_STORAGE.QUOTA_EXCEEDED`. Security errors from disabled storage (private mode) MUST map to `LOCAL_STORAGE.PERMISSION_DENIED`. Unrecognised errors MUST map to `LOCAL_STORAGE.IO_FAILED` preserving the underlying `cause`.
#### Scenario: every code in the registry is thrown by at least one production path
- **WHEN** the spec-conformance suite drives each documented failure mode
- **THEN** every value in `LOCAL_STORAGE_CODES` is observed at least once as the `code` field of a raised `LocalStorageError`.
**Verified by:** `src/local-storage/local-storage.spec-conformance.test.ts::"every LOCAL_STORAGE code is thrown by at least one production path"`
---
### Requirement: Sentinel-write availability detection
The package SHALL expose `isLocalStorageAvailable()` that returns `true` only when both the `localStorage` global is present AND a sentinel `setItem`/`removeItem` probe completes without throwing. When the probe throws (Safari private mode), `isLocalStorageAvailable()` returns `false`. The first method call against an unavailable storage MUST raise `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)`.
#### Scenario: unavailable storage surfaces UNAVAILABLE
- **GIVEN** an injected `StorageLike` whose `setItem` throws `SecurityError`
- **WHEN** the caller invokes `storage.set("k", new Uint8Array([1]))`
- **THEN** the call rejects with `LocalStorageError(LOCAL_STORAGE.PERMISSION_DENIED)` (or `UNAVAILABLE` when the sentinel probe is run separately).
**Verified by:** `src/local-storage-adapter/local-storage-adapter.test.ts::"isLocalStorageAvailable returns false when sentinel write throws"` AND `src/local-storage/local-storage.spec-conformance.test.ts::"every LOCAL_STORAGE code is thrown by at least one production path"`
---
### Requirement: Injectable Storage adapter for testing
The package SHALL accept an optional `storage: StorageLike` option whose default is `defaultLocalStorage()` (the global `localStorage`). Tests MUST be able to substitute a real in-memory implementation; the package MUST NOT use `vi.stubGlobal` or `vi.mock` in its own test suite. The package SHALL export `createInMemoryLocalStorage()` for downstream test packages.
#### Scenario: in-memory adapter is API-compatible with the DOM Storage interface
- **GIVEN** an `InMemoryStorage` instance from `createInMemoryLocalStorage()`
- **WHEN** the caller exercises `setItem`/`getItem`/`removeItem`/`clear`/`length`/`key(i)`
- **THEN** the observable behaviour matches the DOM `Storage` contract (including index stability across mutations).
**Verified by:** `src/in-memory-storage/in-memory-storage.test.ts::"setItem and getItem round-trip"` AND `src/in-memory-storage/in-memory-storage.test.ts::"key(i) returns null for out-of-range indices"` AND `src/in-memory-storage/in-memory-storage.test.ts::"clear empties every slot"`
---
### Requirement: OpenTelemetry instrument surface
The package SHALL emit a complete OTel instrument set under the meter name `@suaveplan/storage-browser-localstorage`: `storage_browser_localstorage.{reads,writes,deletes,errors}_total` counters, `storage_browser_localstorage.op_duration_ms` histogram (labelled by `op` + `outcome`), and `storage_browser_localstorage.io_bytes` histogram (labelled by `op` + `direction`). The package SHALL expose `setStorageBrowserLocalStorageMeterProvider(provider)` and `resetInstruments()` so tests can assert against a real in-memory `MeterProvider`.
#### Scenario: write emits op_duration_ms and writes_total
- **WHEN** the caller invokes `storage.set("k", bytes)` under an installed test `MeterProvider`
- **THEN** the `writes_total` counter records an addition with `{ op: "set", outcome: "success" }` AND the `op_duration_ms` histogram records a sample.
**Verified by:** `src/otel/otel.test.ts::"recordOp emits the documented counter and histogram set"` AND `src/local-storage/local-storage.spec-conformance.test.ts::"emits OTel instruments through an in-memory MeterProvider"`
---
### Requirement: Explicit factory registration
The package SHALL expose `registerLocalStorageProvider()` that registers `LocalStorageStorage` against the `@suaveplan/storage` factory under the slot `"localstorage"`. Registration MUST be idempotent — repeated calls do nothing once the slot is occupied. The package MUST NOT self-register at module-evaluation time so `sideEffects: false` consumers can tree-shake the factory path away.
#### Scenario: createStorage resolves the localstorage provider after registration
- **GIVEN** a fresh process where no provider is registered
- **WHEN** the caller invokes `registerLocalStorageProvider()` followed by `createStorage({ type: "localstorage", config: { storage: inMemory } })`
- **THEN** the returned instance is a `LocalStorageStorage`.
**Verified by:** `src/registry/registry.test.ts::"registers the localstorage provider and resolves it via createStorage"` AND `src/registry/registry.test.ts::"is idempotent across repeated registerLocalStorageProvider calls"`
<!-- markdownlint-enable MD024 -->