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

7.2 KiB

Migrate storage-browser-localstorage into genesis

Package: @suaveplan/storage-browser-localstorage Status: PROPOSED Type: new-package (migration from suaveplan)

Why

The upstream @suaveplan/storage-browser package combines two distinct browser-tier storage backends (IndexedDB and the synchronous localStorage API) behind a single barrel and a shared sideEffects-coupled registry. Genesis is splitting the package into two focused, tree-shake-clean siblings — @suaveplan/storage-browser-indexeddb and @suaveplan/storage-browser-localstorage — that each register explicitly against the @suaveplan/storage factory. This change ships the LocalStorage half. Splitting reduces bundle size for consumers that only need one backend, removes the import-time side-effect smell flagged by the upstream enterprise audit, and produces a typed LOCAL_STORAGE.* error registry aligned with the OPFS sibling that landed today.

What changes

  • ADDED: LocalStorageStorage class extending AbstractStorage from @suaveplan/storage/abstract. Synchronous Storage API presented through the AbstractStorage async surface.
  • ADDED: createLocalStorageStorage(options?) factory.
  • ADDED: LocalStorageStorageOptions + LocalStorageStorageOptionsSchema Zod boundary.
  • ADDED: StorageLike structural interface mirroring the DOM Storage shape (getItem/setItem/removeItem/clear/length/key(i)) — injectable adapter.
  • ADDED: defaultLocalStorage() adapter wrapping globalThis.localStorage, plus isLocalStorageAvailable() feature detector with sentinel-write probe.
  • ADDED: createInMemoryLocalStorage() real-implementation Storage adapter for testing (no mocks).
  • ADDED: LOCAL_STORAGE_CODES registry (NOT_FOUND, PERMISSION_DENIED, QUOTA_EXCEEDED, UNAVAILABLE, INVALID_KEY, KEY_TOO_LONG, INVALID_OPTIONS, ABORTED, IO_FAILED, CORRUPT_PAYLOAD) + LocalStorageError extends SuaveplanError<LocalStorageErrorCode>.
  • ADDED: sanitizeKey(key) boundary (rejects empty/non-string/control-char/absolute/../null-byte; caps at 256 chars).
  • ADDED: encodeEnvelope / decodeEnvelope — base64 payload + JSON metadata wire format with expiresAt TTL.
  • ADDED: registerLocalStorageProvider() explicit-call factory registration under slot "localstorage".
  • ADDED: Full OTel surface — storage_browser_localstorage.{reads,writes,deletes,errors}_total, op_duration_ms, io_bytes instruments with setStorageBrowserLocalStorageMeterProvider override + resetInstruments.

Out of scope

  • IndexedDB backend — separate parallel migration storage-browser-indexeddb.
  • Compression / encryption wrappers — deferred; out of scope for the base adapter migration.
  • BroadcastChannel cross-tab sync — deferred; future feature, separate change.
  • Proactive TTL sweep timer — deferred; this migration ships lazy-on-read expiry only.
  • React hooks layer — separate storage-browser-react consumer, future package.

Source mapping

This change derives from ../suaveplan/enterprise/storage-browser.md.

Requirement Source lines Notes
AbstractStorage extension with async API over Uint8Array L12 Upstream describes both adapters extending AbstractStorage; preserved verbatim for LocalStorage.
Base64 + JSON envelope wire format L12, L24 Reworded — kept Base64-of-Uint8Array payload, JSON envelope { v, payload, meta, expiresAt? }.
Namespace prefix + TTL expiry on read L24 Verbatim contract — ${namespace}:${key} keyspace, lazy delete on get.
Prefix-based listing with pagination L12 Verbatim — list({ prefix, limit, offset }) iterates localStorage.key(i).
Metadata: size, mimeType, createdAt, updatedAt, custom L12 Verbatim.
StorageQuotaError quota wrapping L12 Extended — genesis uses typed LOCAL_STORAGE.QUOTA_EXCEEDED covering code 22 and code 1014 (Firefox) in addition to QuotaExceededError.
Explicit registerLocalStorageProvider() factory registration (no module-load side effects) L48, L84 Extended — upstream had import-time side effects; the audit's "Fix sideEffects vs. registry modules" recommendation is implemented as an explicit-call function so sideEffects: false holds.
Sub-path exports ./localstorage, ./registry L49, L102 Extended — every feature module gets its own subpath per genesis §6.
Co-located .md docs for every implementation file L50, L86, L100 Verbatim — required gate.

Requirements with no source line

Requirement Justification
AbortSignal threading on every public method Genesis baseline added to every storage adapter since OPFS; pre-aborted signals throw LOCAL_STORAGE.ABORTED before any localStorage call.
Injectable Storage adapter (StorageLike interface) Required by the genesis "no mocks" rule (CLAUDE.md §10.2) — tests must run against a real in-memory implementation rather than vi.stubGlobal.
Injectable Clock from @suaveplan/types Genesis cross-package contract — every TTL/timestamp surface accepts a Clock for deterministic tests.
Zod boundary schemas on options Genesis @suaveplan/validation-aligned baseline.
Typed LOCAL_STORAGE_CODES registry via defineCodes Genesis baseline (every package owns a frozen registry); upstream used a single StorageQuotaError.
Sentinel-write availability probe with UNAVAILABLE error Old Safari private mode throws on setItem; without a probe, callers hit a generic Error from the first write.
Full OTel surface (instruments + counters + histograms) Genesis baseline; upstream had no observability.
CORRUPT_PAYLOAD error code Genesis hardening — malformed base64 / JSON envelope returns a typed boundary error instead of silently swallowing as null.

Verification approach

  • src/local-storage/local-storage.test.ts — CRUD, list/pagination, metadata, TTL expiry, namespace isolation, AbortSignal pre-abort, key boundary, corrupt-payload graceful degrade.
  • src/local-storage/local-storage.spec-conformance.test.ts — AbstractStorage instance, capabilities matrix, every LOCAL_STORAGE_CODES.* thrown by a production path, OTel instruments emitted through a real in-memory MeterProvider.
  • src/key-sanitizer/key-sanitizer.test.ts — every boundary rule.
  • src/envelope/envelope.test.ts — round-trip, corrupt detection.
  • src/in-memory-storage/in-memory-storage.test.ts — fixture self-test (length, key(i), getItem/setItem/removeItem, clear, quota).
  • src/local-storage-adapter/local-storage-adapter.test.ts — defaultLocalStorage() + isLocalStorageAvailable() probe.
  • src/otel/otel.test.ts — recorder semantics, per-op counter routing.
  • src/registry/registry.test.ts — registerLocalStorageProvider() idempotency + createStorage({ type: "localstorage", ... }) roundtrip.

Migration impact

None — additive only. New package; no existing genesis consumers. Downstream genesis packages that need browser localStorage may import this package once available.

Impact

Affected dependents

bunx turbo ls --filter=...@suaveplan/storage-browser-localstorage — no dependents (package is brand new on the genesis branch). No work needed in any other package.