12 KiB
Capability Spec Delta: storage (enterprise-browser-storage-browser)
Package: @suaveplan/storage-browser
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: IndexedDB adapter with CRUD, TTL, and metadata
IndexedDBStorage SHALL extend AbstractStorage and provide get, set, delete, clear, and list over an IndexedDB object store. set SHALL accept optional StorageMetadata including size, mimeType, createdAt, updatedAt, and custom fields. set SHALL write an expiresAt timestamp when ttl is configured. list SHALL support prefix filtering and pagination via ListOptions. get SHALL return null for expired entries and delete them lazily.
Scenario: round-trip with metadata
- WHEN
set(key, data, { mimeType: "image/png" })is called followed byget(key) - THEN the returned
StorageEntrycontains the original data and metadata
Scenario: expired entry returns null
- WHEN
set(key, data, { ttl: 1 })is called, the clock advances past the TTL, andget(key)is called - THEN the returned promise resolves to
null
Scenario: list paginates
- WHEN
list(prefix, { limit: 2 })is called with 5 matching keys - THEN exactly 2 entries are returned and a continuation cursor is provided
Verified by: src/indexeddb/adapter/indexeddb.test.ts::"round-trip with metadata" AND src/indexeddb/adapter/indexeddb.test.ts::"expired entry returns null" AND src/indexeddb/adapter/indexeddb.test.ts::"list paginates"
Requirement: LocalStorage adapter with Base64 envelope and TTL
LocalStorageStorage SHALL extend AbstractStorage and store payloads in localStorage as a JSON envelope with Base64-encoded Uint8Array data. It SHALL namespace keys with a configurable prefix. TTL expiry SHALL be evaluated on read: expired entries return null and are deleted.
Scenario: round-trip with binary data
- WHEN
set(key, binaryData)is called followed byget(key) - THEN the returned
Uint8Arrayequals the original byte-for-byte
Scenario: namespace isolation
- WHEN two
LocalStorageStorageinstances are constructed with different namespaces and write the same key - THEN each instance reads only its own value
Scenario: TTL expiry on read
- WHEN
set(key, data, { ttl: 1 })is called, the clock advances past the TTL, andget(key)is called - THEN the returned promise resolves to
nulland the key is removed fromlocalStorage
Verified by: src/localstorage/adapter/localstorage.test.ts::"round-trip with binary data" AND src/localstorage/adapter/localstorage.test.ts::"namespace isolation" AND src/localstorage/adapter/localstorage.test.ts::"TTL expiry on read"
Requirement: Injectable Clock for deterministic TTL testing
Both IndexedDBStorage and LocalStorageStorage SHALL accept an optional clock?: Clock in their constructor config. When provided, the adapter SHALL use the injected clock for all TTL calculations (writing expiresAt, evaluating expiry on read, and proactive sweep scheduling). When omitted, the adapter SHALL fall back to the system clock.
Scenario: injected clock controls TTL expiry
- WHEN
set(key, data, { ttl: 1000 })is called with an injectedClockset tot=0 - THEN
get(key)returns the entry att=500and returnsnullatt=1001
Scenario: missing clock uses system time
- WHEN no
clockis provided andset(key, data, { ttl: 1 })is called - THEN the entry expires after the real TTL duration has elapsed
Verified by: src/indexeddb/adapter/indexeddb.test.ts::"injected clock controls TTL expiry" AND src/localstorage/adapter/localstorage.test.ts::"injected clock controls TTL expiry"
Requirement: Explicit registration — no import-time side effects
Auto-registration side effects from the main-branch registry modules SHALL be removed. The package SHALL export explicit registerIndexedDBStorage() and registerLocalStorageStorage() functions from ./register. sideEffects SHALL be false in package.json.
Scenario: import does not register
- WHEN the package is imported without calling
register*() - THEN the
AbstractStoragefactory does not contain either backend
Scenario: explicit registration works
- WHEN
registerIndexedDBStorage()andregisterLocalStorageStorage()are called - THEN the factory resolves both backends by name
Verified by: src/register.test.ts::"import does not register" AND src/register.test.ts::"explicit registration works"
Requirement: Proactive TTL sweep
IndexedDBStorage SHALL schedule a background TTL sweep via requestIdleCallback (or setTimeout fallback). The sweep SHALL use the existing expiresAt index to cursor over and batch-delete expired rows. It SHALL not block the main thread.
Scenario: sweep deletes expired rows
- WHEN expired rows exist and the sweep callback fires
- THEN those rows are deleted from the object store
Scenario: sweep leaves fresh rows
- WHEN the sweep callback fires and only unexpired rows exist
- THEN no rows are deleted
Verified by: src/indexeddb/ttl/ttl.test.ts::"sweep deletes expired rows" AND src/indexeddb/ttl/ttl.test.ts::"sweep leaves fresh rows"
Requirement: Transactional batch API
IndexedDBStorage SHALL expose batch(ops: BatchOperation[]) executing multiple set and delete operations in a single IndexedDB transaction. The transaction SHALL be atomic: either all operations succeed or all are rolled back.
Scenario: batch commits atomically
- WHEN
batch([{ op: "set", key, data }, { op: "delete", key: otherKey }])is called - THEN both operations are visible or neither is
Scenario: batch rollback on error
- WHEN
batchincludes an invalid key that triggers an IDB error mid-transaction - THEN no partial writes remain in the store
Verified by: src/indexeddb/adapter/indexeddb.test.ts::"batch commits atomically" AND src/indexeddb/adapter/indexeddb.test.ts::"batch rollback on error"
Requirement: Encrypted wrapper
EncryptedStorage SHALL wrap any AbstractStorage and encrypt payloads and metadata at rest using AES-GCM via @suaveplan/crypto. It SHALL throw STORAGE_ENCRYPTION_FAILED when key material is invalid or encryption fails.
Scenario: encrypted round-trip
- WHEN
set(key, data)is called throughEncryptedStorageand thenget(key) - THEN the decrypted data equals the original
Scenario: raw storage sees ciphertext
- WHEN
get(key)is called on the inner storage directly - THEN the returned payload is not equal to the plaintext
Verified by: src/wrappers/encrypted/encrypted.test.ts::"encrypted round-trip" AND src/wrappers/encrypted/encrypted.test.ts::"raw storage sees ciphertext"
Requirement: Compressed wrapper
CompressedStorage SHALL wrap any AbstractStorage and apply CompressionStream (gzip) to payloads above a configurable byte threshold. It SHALL throw STORAGE_COMPRESSION_FAILED on stream errors.
Scenario: large payload is compressed
- WHEN
set(key, largeData)is called throughCompressedStoragewith data above the threshold - THEN the inner storage receives fewer bytes than the original
Scenario: small payload passes through
- WHEN
set(key, smallData)is called with data below the threshold - THEN the inner storage receives the original bytes unchanged
Verified by: src/wrappers/compressed/compressed.test.ts::"large payload is compressed" AND src/wrappers/compressed/compressed.test.ts::"small payload passes through"
Requirement: BroadcastChannel cross-tab sync
IndexedDBStorage and LocalStorageStorage SHALL accept an optional broadcastChannel config. When configured, set, delete, and clear SHALL emit typed events on the channel so other tabs can invalidate their views.
Scenario: cross-tab invalidation event
- WHEN
set(key, data)is called in tab A with a broadcast channel configured - THEN tab B receives a
StorageEventwithtype: "set"and the same key
Scenario: delete emits clear event
- WHEN
clear()is called in tab A - THEN tab B receives a
StorageEventwithtype: "clear"
Verified by: src/sync/broadcast/broadcast-sync.test.ts::"cross-tab invalidation event" AND src/sync/broadcast/broadcast-sync.test.ts::"delete emits clear event"
Requirement: Observable store
The package SHALL export observeStorage(storage, key): AsyncIterable<StorageEvent> backed by BroadcastChannel. The iterator SHALL yield events when the key is mutated in any tab.
Scenario: iterator yields on remote mutation
- WHEN
observeStorage(storage, key)is consumed and another tab callsset(key, data) - THEN the iterator yields a
StorageEventwith the new data
Scenario: iterator completes on abort
- WHEN the
AbortSignalpassed to the observation is aborted - THEN the iterator returns (not throws) cleanly
Verified by: src/observable/observe-storage/observe-storage.test.ts::"iterator yields on remote mutation" AND src/observable/observe-storage/observe-storage.test.ts::"iterator completes on abort"
Requirement: Optimistic concurrency
IndexedDBStorage SHALL support compare-and-set via updatedAt metadata. set SHALL read the existing updatedAt, and if it changed since the caller last read the key, throw STORAGE_CONCURRENCY_CONFLICT.
Scenario: concurrent write is detected
- WHEN tab A reads a key, tab B writes the same key, and then tab A writes with the original
updatedAt - THEN tab A's write throws
STORAGE_CONCURRENCY_CONFLICT
Scenario: uncontended write succeeds
- WHEN
set(key, data)is called with a matchingupdatedAt - THEN the write succeeds and
updatedAtis advanced
Verified by: src/indexeddb/adapter/indexeddb.test.ts::"concurrent write is detected" AND src/indexeddb/adapter/indexeddb.test.ts::"uncontended write succeeds"
Requirement: Stable error codes
The package SHALL register the following codes at load time: STORAGE_QUOTA_EXCEEDED, STORAGE_KEY_NOT_FOUND, STORAGE_ENCRYPTION_FAILED, STORAGE_COMPRESSION_FAILED, STORAGE_CONCURRENCY_CONFLICT. All errors SHALL extend SuaveplanError.
Scenario: quota exceeded throws STORAGE_QUOTA_EXCEEDED
- WHEN a write would exceed the browser storage quota
- THEN a
SuaveplanErrorwith codeSTORAGE_QUOTA_EXCEEDEDis thrown
Scenario: all codes registered
- WHEN
registerStorageBrowserErrorCodes()is called - THEN every code in the union is present in the global registry
Verified by: src/indexeddb/adapter/indexeddb.test.ts::"quota exceeded throws STORAGE_QUOTA_EXCEEDED" AND src/error-codes.test.ts::"all codes registered"
Requirement: OTel metrics per backend
Every CRUD operation on both backends SHALL emit a counter storage.<operation>_total with label backend (indexeddb or localstorage). A histogram storage.op_ms with label backend and operation SHALL record latency. A counter storage.ttl_evictions_total SHALL record proactive and lazy TTL deletions.
Scenario: indexeddb metrics are labeled
- WHEN
IndexedDBStorage.set(key, data)is called - THEN
storage.writes_total{backend="indexeddb"}andstorage.op_ms{backend="indexeddb",operation="set"}are incremented
Scenario: ttl evictions are counted
- WHEN an expired entry is deleted by the proactive sweep
- THEN
storage.ttl_evictions_totalis incremented
Verified by: src/indexeddb/adapter/indexeddb.spec-conformance.test.ts::"indexeddb metrics are labeled" AND src/indexeddb/ttl/ttl.spec-conformance.test.ts::"ttl evictions are counted"