12 KiB
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 byget("k") - THEN the returned
Uint8Arrayis 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 keyk - THEN the underlying
localStoragevalue for the namespaced key is a JSON string with a base64payloadfield that decodes back to the original bytes.
Scenario: corrupt envelope raises CORRUPT_PAYLOAD
- WHEN the underlying
localStorageslot for a key is manually overwritten with non-JSON garbage - THEN
get("k")raisesLocalStorageErrorwith codeLOCAL_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
keysis exactly["user/1", "user/2"].
Scenario: foreign localStorage keys are ignored
- GIVEN a sibling tool wrote
other-app-keydirectly tolocalStorage - WHEN
clear()is called - THEN only the package's namespaced entries are removed;
other-app-keyis 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
getreturnsundefined,existsreturnsfalse, the underlyinglocalStorageslot 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 underlyingStorage.getItemis 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
AbortControllerwhose 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_CODESis observed at least once as thecodefield of a raisedLocalStorageError.
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
StorageLikewhosesetItemthrowsSecurityError - WHEN the caller invokes
storage.set("k", new Uint8Array([1])) - THEN the call rejects with
LocalStorageError(LOCAL_STORAGE.PERMISSION_DENIED)(orUNAVAILABLEwhen 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
InMemoryStorageinstance fromcreateInMemoryLocalStorage() - WHEN the caller exercises
setItem/getItem/removeItem/clear/length/key(i) - THEN the observable behaviour matches the DOM
Storagecontract (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 testMeterProvider - THEN the
writes_totalcounter records an addition with{ op: "set", outcome: "success" }AND theop_duration_mshistogram 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 bycreateStorage({ 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"