17 KiB
OpenSpec Change: enterprise-browser-storage-browser
- Change ID: enterprise-browser-storage-browser
- Package:
@suaveplan/storage-browser - Category: browser
- Target path:
browser/storage/storage-browser - Status: PROPOSED
- Date: 2026-04-24
- Reference spec:
/mnt/s/web/suaveplan/enterprise/storage-browser.md
Summary
Deliver @suaveplan/storage-browser on the genesis branch at enterprise quality. The package provides browser-side AbstractStorage adapters — IndexedDBStorage and LocalStorageStorage — with a unified async API over Uint8Array payloads, TTL expiration, prefix-based listing with pagination, StorageQuotaError, and auto-registration. Genesis adds BroadcastChannel cross-tab sync, encrypted + compressed wrapper adapters, proactive TTL sweep, transactional batch API, optimistic concurrency, observable store, and corrected sideEffects declaration.
Current-state audit (genesis)
Fresh build — does not exist on genesis yet.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/storage (workspace:^) |
AbstractStorage, StorageError, StorageQuotaError, factory |
@suaveplan/crypto (workspace:^) |
AES-GCM encrypted wrapper |
@suaveplan/events (workspace:^) |
Observable store events |
@suaveplan/error (workspace:^) |
Typed errors |
@suaveplan/types (workspace:^) |
LoggerLike |
@suaveplan/logger (workspace:^) |
Structured logging |
@suaveplan/broadcast-channel (workspace:^) |
Cross-tab invalidation |
@suaveplan/schema-registry (workspace:^) |
Register config schemas |
zod (catalog:forms) |
Config + entry schemas |
@opentelemetry/api (catalog:observability) |
Storage operation metrics |
tslib (catalog:build) |
Runtime helpers |
Dev: vitest, @vitest/coverage-istanbul, fake-indexeddb (^6.0.0) — must be added to catalog:testing before implementation, @playwright/test, @suaveplan/testing.
Preserved public API surface
// IndexedDB adapter
class IndexedDBStorage extends AbstractStorage {
constructor(config: IndexedDBStorageConfig)
get(key: string): Promise<StorageEntry | null>
set(key: string, data: Uint8Array, metadata?: StorageMetadata): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
list(prefix?: string, options?: ListOptions): Promise<StorageEntry[]>
batch(ops: BatchOperation[]): Promise<void>
}
// LocalStorage adapter
class LocalStorageStorage extends AbstractStorage {
constructor(config: LocalStorageStorageConfig)
get(key: string): Promise<StorageEntry | null>
set(key: string, data: Uint8Array, metadata?: StorageMetadata): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
list(prefix?: string, options?: ListOptions): Promise<StorageEntry[]>
}
// Wrapper adapters
class EncryptedStorage extends AbstractStorage { constructor(inner: AbstractStorage, keyMaterial: CryptoKey) }
class CompressedStorage extends AbstractStorage { constructor(inner: AbstractStorage, threshold?: number) }
// Observable
function observeStorage(storage: AbstractStorage, key: string): AsyncIterable<StorageEvent>
// Register functions (explicit, not auto-import side effects)
function registerIndexedDBStorage(): void
function registerLocalStorageStorage(): void
// Config types
interface IndexedDBStorageConfig { dbName: string; storeName: string; ttl?: number; schemaVersion?: number; clock?: Clock }
interface LocalStorageStorageConfig { namespace: string; ttl?: number; clock?: Clock }
Subpath exports: ".", "./indexeddb", "./localstorage", "./register", "./types".
New additions for genesis
1. sideEffects fix
Main-branch sideEffects: false conflicts with the two registry.ts side-effect imports. Genesis moves auto-registration to explicit registerIndexedDBStorage() / registerLocalStorageStorage() call-site functions under a ./register subpath. sideEffects remains false for all other subpaths.
2. Proactive TTL sweep
IndexedDBStorage sweeps expired rows via requestIdleCallback + the expiresAt IDB index. No expired rows consume quota between reads.
3. Transactional batch API
batch(ops: BatchOperation[]) executes multiple set/delete operations in a single IDB transaction for atomic multi-key updates.
4. Encrypted wrapper
EncryptedStorage wraps any AbstractStorage with AES-GCM (via @suaveplan/crypto) for at-rest encryption of payloads and metadata.
5. Compressed wrapper
CompressedStorage applies CompressionStream (gzip) above a configurable byte threshold before writing to the underlying adapter.
6. BroadcastChannel cross-tab sync
IndexedDBStorage and LocalStorageStorage accept an optional broadcastChannel?: TypedBroadcastChannel; emit set/delete/clear events so other tabs invalidate their views.
7. Observable store
observeStorage(storage, key): AsyncIterable<StorageEvent> on top of the BroadcastChannel layer.
8. Optimistic concurrency
Compare-and-set using updatedAt metadata so concurrent-tab writes don't silently clobber each other.
9. Error codes
STORAGE_QUOTA_EXCEEDED, STORAGE_KEY_NOT_FOUND, STORAGE_ENCRYPTION_FAILED, STORAGE_COMPRESSION_FAILED, STORAGE_CONCURRENCY_CONFLICT.
10. OTel metrics
Counter storage.reads_total{backend}, counter storage.writes_total{backend}, counter storage.deletes_total{backend}, counter storage.ttl_evictions_total, histogram storage.op_ms.
File layout
browser/storage/storage-browser/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
register.ts (explicit registerIndexedDBStorage + registerLocalStorageStorage)
error-codes.ts
indexeddb/
adapter/
indexeddb.ts (orchestrator ≤300 lines)
indexeddb.test.ts
indexeddb.md
indexeddb.types.ts
index.ts
store/ (store.ts, store.test.ts, store.md)
ttl/ (ttl.ts, ttl.test.ts, ttl.md)
migrations/ (migrations.ts, migrations.test.ts, migrations.md)
localstorage/
adapter/
localstorage.ts
localstorage.test.ts
localstorage.md
localstorage.types.ts
index.ts
store/
ttl/
wrappers/
encrypted/ (encrypted.ts, encrypted.test.ts, encrypted.md)
compressed/ (compressed.ts, compressed.test.ts, compressed.md)
sync/
broadcast/
broadcast-sync.ts
broadcast-sync.test.ts
broadcast-sync.md
index.ts
observable/
observe-storage/
observe-storage.ts
observe-storage.test.ts
observe-storage.md
index.ts
types/
types.ts
types.md
index.ts
tests/
integration.test.ts
e2e/
storage-browser.spec.ts (Chromium/WebKit/Firefox cross-tab, encrypted, compressed)
Source mapping
This change derives from
../suaveplan/enterprise/storage-browser.md.
| Requirement | Source lines | Notes |
|---|---|---|
| IndexedDB adapter with CRUD, TTL, and metadata | L13, L21-L22 | Purpose and current state assessment |
| LocalStorage adapter with Base64 envelope and TTL | L13, L24-L25 | Purpose and current state assessment |
| Explicit registration — no import-time side effects | L48-L49 | Fix sideEffects vs registry modules |
| Proactive TTL sweep | L69 | Proactive TTL sweep using expiresAt index |
| Transactional batch API | L70 | Transactional batch API |
| Encrypted wrapper | L71 | Encrypted wrapper using WebCrypto AES-GCM |
| Compressed wrapper | L72 | Compressed wrapper using CompressionStream |
| BroadcastChannel cross-tab sync | L73 | BroadcastChannel cross-tab sync |
| Observable store | L78 | Observable store AsyncIterable |
| Optimistic concurrency | L77 | Optimistic concurrency via updatedAt |
| Stable error codes | L63 | Error hierarchy via SuaveplanError |
| OTel metrics per backend | L44, L72 | Observability surface — counters, histograms, spans |
Requirements with no source line
| Requirement | Justification |
|---|---|
Subpath exports for ./indexeddb, ./localstorage, ./register, ./types |
Genesis-specific tree-shaking requirement (AGENTS.md Section 6) |
| 100% coverage threshold | Genesis-wide policy (openspec/AGENTS.md Section 7), stricter than enterprise 95% floor |
fake-indexeddb for unit tests |
Genesis testing strategy — no mocks in production, but test doubles permitted in tests |
| Playwright E2E cross-tab invalidation | Genesis web-app verification rules (CLAUDE.md) require multi-tab E2E |
Out of scope
- Server-side rendering (SSR) storage emulation.
- Service Worker
CacheStorageorcachesAPI integration. - OPFS backend (handled by
@suaveplan/storage-browser-opfs). - Multi-origin storage synchronization.
- Offline-first conflict resolution (CRDTs, etc.).
Gap analysis: sub-standard → enterprise
- Security gaps: Prior art stored plaintext in
localStoragewithout encryption wrappers. Genesis addsEncryptedStorage(AES-GCM via@suaveplan/crypto) and enforces origin-scoped namespaces. - Reliability gaps: Prior art lacked atomic batch operations and could leave partial state on tab crash. Genesis adds transactional
batch()for IndexedDB and optimistic concurrency control viaupdatedAtmetadata. - Observability gaps: Prior art emitted no metrics. Genesis adds per-backend OTel counters, histograms, and structured logs; BroadcastChannel cross-tab sync is observable.
- Testability gaps: Prior art used brittle mock-based tests. Genesis uses
fake-indexeddbfor deterministic unit tests, Playwright E2E for cross-tab validation, and 100% coverage mandate. - Operational gaps: Prior art had silent TTL expiry accumulation. Genesis adds proactive TTL sweep via
requestIdleCallbackandStorageQuotaErrorbefore write failures.
Security model
- Threat model: Same-origin attackers with JS execution can read/write storage. Threats arise from XSS injecting keys/values, and from local storage inspection on shared devices.
- Boundary diagram:
IndexedDBStorage/LocalStorageStoragesit inside the origin boundary.EncryptedStorageadds a cryptographic boundary inside the origin.BroadcastChannelcrosses tab boundaries within the same origin. - Input sanitization: Keys are sanitized (no null bytes, max length).
StorageMetadatais validated via Zod.CryptoKeyfor encryption is non-extractable and scoped to the wrapper. - Side-channel resistance:
EncryptedStorageuses AES-GCM with random IVs per write; no static nonce. Compression is applied before encryption to mitigate CRIME/BREACH (payloads are local, but defense-in-depth applied).
Performance SLOs
| Operation | p50 | p99 | Throughput | Memory | Big-O |
|---|---|---|---|---|---|
IndexedDB get |
<2 ms | <10 ms | 5k ops/s | payload size + object store overhead | O(1) |
IndexedDB set (small) |
<3 ms | <15 ms | 3k ops/s | 2× payload (transaction journal) | O(1) |
LocalStorage get |
<1 ms | <5 ms | 10k ops/s | 5 MiB total quota | O(1) |
LocalStorage set |
<1 ms | <5 ms | 10k ops/s | 5 MiB total quota | O(1) |
batch (100 ops) |
<10 ms | <50 ms | 100 batches/s | sum of payloads | O(n) |
| TTL sweep | <5 ms | <20 ms | 1 sweep/min | cursor overhead | O(e) |
- Scalability limits: IndexedDB tested up to 100k entries / 500 MiB. LocalStorage hard-capped at ~5 MiB by browser; adapter throws
STORAGE_QUOTA_EXCEEDEDbefore bound.
Observability plan
- OTel spans:
storage.browser.get,storage.browser.set,storage.browser.delete,storage.browser.list,storage.browser.batch,storage.browser.ttl_sweep - Counters:
storage.reads_total{backend},storage.writes_total{backend},storage.deletes_total{backend},storage.ttl_evictions_total,storage.quota_exceeded_total,storage.concurrency_conflict_total - Histograms:
storage.op_ms{backend,op},storage.batch_size,storage.encrypt_ms,storage.compress_ms - Structured logging:
debuglog per operation withbackend,key(hashed for PII),trace_id,duration_ms,error_code. - Health checks:
storage.browser.healthprobe performs aset/get/deleteround-trip on a sentinel key; fails if quota is exhausted or IDB is corrupt. - Alerting thresholds:
storage.quota_exceeded_total> 10/min triggers warning;storage.op_msp99 > 100 ms for 5 min triggers investigation;storage.concurrency_conflict_totalspike indicates cross-tab write collision.
Dependency audit
| Runtime dep | Version | Justification | Supply-chain risk | Tree-shakeable | License |
|---|---|---|---|---|---|
zod |
catalog:forms |
Config + entry schema validation | Low | Yes | MIT |
@opentelemetry/api |
catalog:observability |
Metrics and spans | Low | Yes | Apache-2.0 |
tslib |
catalog:build |
TS runtime helpers | Low | Yes | 0BSD |
@suaveplan/storage |
workspace:^ |
AbstractStorage contract | Low | Yes | Proprietary |
@suaveplan/crypto |
workspace:^ |
AES-GCM wrapper | Low | Yes | Proprietary |
@suaveplan/events |
workspace:^ |
Observable store events | Low | Yes | Proprietary |
@suaveplan/error |
workspace:^ |
Typed errors | Low | Yes | Proprietary |
@suaveplan/types |
workspace:^ |
LoggerLike | Low | Yes | Proprietary |
@suaveplan/logger |
workspace:^ |
Structured logging | Low | Yes | Proprietary |
@suaveplan/broadcast-channel |
workspace:^ |
Cross-tab sync | Low | Yes | Proprietary |
@suaveplan/schema-registry |
workspace:^ |
Config schema registration | Low | Yes | Proprietary |
Operational readiness
- Graceful degradation: If
BroadcastChannelis unavailable (e.g., Firefox private mode), sync degrades to silent mode; storage operations continue. IfCompressionStreamis missing,CompressedStoragebypasses compression. - Resource limits: TTL sweep is gated by
requestIdleCallbacktimeout (50 ms) to avoid blocking the main thread.batchsize is unbounded by spec but documented with a recommended 500-op soft limit. - Retry policies: IndexedDB transactions retry once on
TransactionInactiveErrororQuotaExceededErrorafter a 100 ms delay. LocalStorage is synchronous under the hood; no retries. - Circuit breakers:
QuotaExceededErrortriggers a 5-second circuit breaker on writes to prevent spin-loops; reads remain open. - Upgrade compatibility: Schema version bumps in IndexedDB use standard IDB
onupgradeneededcallbacks; old data is preserved unless a migration explicitly drops it. Subpath exports ensure tree-shaking is stable across versions.
Verification approach
src/indexeddb/adapter/indexeddb.test.ts— covers IndexedDB CRUD, TTL, batch, concurrency, quotasrc/indexeddb/adapter/indexeddb.spec-conformance.test.ts— pins type-level conformance, OTel metrics, strict-mode behaviourssrc/indexeddb/store/store.test.ts— covers IDB open/upgrade/transaction internalssrc/indexeddb/ttl/ttl.test.ts— covers proactive sweepsrc/indexeddb/migrations/migrations.test.ts— covers schema-version upgrade callbackssrc/localstorage/adapter/localstorage.test.ts— covers LocalStorage CRUD, namespace isolation, TTLsrc/wrappers/encrypted/encrypted.test.ts— covers AES-GCM encryption round-tripsrc/wrappers/compressed/compressed.test.ts— covers gzip threshold logicsrc/sync/broadcast/broadcast-sync.test.ts— covers cross-tab event emissionsrc/observable/observe-storage/observe-storage.test.ts— covers AsyncIterable events and abortsrc/register.test.ts— covers explicit factory registrationsrc/error-codes.test.ts— covers code registrationtests/integration.test.ts— covers adapter stacking (encrypted + compressed over IndexedDB)e2e/storage-browser.spec.ts— Playwright cross-tab, quota, encryption visibility tests- 100% coverage target: Istanbul thresholds in
vitest.config.tsenforce 100% branches, functions, lines, and statements. - Property-based test invariants:
fast-checkverifies that arbitrarybatchsequences are equivalent to individual ops; arbitrary key prefixes never collide across namespaces; TTL expiry times are monotonic. - Load/stress criteria: 10k rapid
set/getcycles in a single tab must complete in <30 s with no IDB transaction timeouts; cross-tab stress test with 5 tabs each writing 1k keys must converge to consistent state within 5 s. - Security audit checklist:
- AES-GCM key non-extractability —
CryptoKeyusages cannot be exported. - IV uniqueness — encrypted wrapper never reuses an IV across two writes.
- No plaintext metadata leakage —
StorageMetadatainsideEncryptedStorageis encrypted as part of the payload envelope. - LocalStorage namespace isolation — two adapters with different namespaces cannot read each other’s keys.
- XSS injection via key — keys containing
<script>or null bytes are sanitized before storage. - Quota exhaustion handling —
STORAGE_QUOTA_EXCEEDEDis thrown before the underlying API throws an opaque DOMException.
- AES-GCM key non-extractability —
Impact
Downstream: @suaveplan/cache-browser, @suaveplan/analytics-browser, @suaveplan/react-tour, any package using browser storage.
Breaking changes: Auto-registration side effects removed; callers must import ./register explicitly.
Prerequisites: @suaveplan/storage, @suaveplan/crypto, @suaveplan/events, @suaveplan/broadcast-channel on Verdaccio.