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

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 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"