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:
LocalStorageStorageclass extendingAbstractStoragefrom@suaveplan/storage/abstract. SynchronousStorageAPI presented through the AbstractStorage async surface. - ADDED:
createLocalStorageStorage(options?)factory. - ADDED:
LocalStorageStorageOptions+LocalStorageStorageOptionsSchemaZod boundary. - ADDED:
StorageLikestructural interface mirroring the DOMStorageshape (getItem/setItem/removeItem/clear/length/key(i)) — injectable adapter. - ADDED:
defaultLocalStorage()adapter wrappingglobalThis.localStorage, plusisLocalStorageAvailable()feature detector with sentinel-write probe. - ADDED:
createInMemoryLocalStorage()real-implementationStorageadapter for testing (no mocks). - ADDED:
LOCAL_STORAGE_CODESregistry (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 withexpiresAtTTL. - 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_bytesinstruments withsetStorageBrowserLocalStorageMeterProvideroverride +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-reactconsumer, 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—AbstractStorageinstance, capabilities matrix, everyLOCAL_STORAGE_CODES.*thrown by a production path, OTel instruments emitted through a real in-memoryMeterProvider.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.