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

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. (See openspec/AGENTS.md Section 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 by get(key)
  • THEN the returned StorageEntry contains the original data and metadata

Scenario: expired entry returns null

  • WHEN set(key, data, { ttl: 1 }) is called, the clock advances past the TTL, and get(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 by get(key)
  • THEN the returned Uint8Array equals the original byte-for-byte

Scenario: namespace isolation

  • WHEN two LocalStorageStorage instances 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, and get(key) is called
  • THEN the returned promise resolves to null and the key is removed from localStorage

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 injected Clock set to t=0
  • THEN get(key) returns the entry at t=500 and returns null at t=1001

Scenario: missing clock uses system time

  • WHEN no clock is provided and set(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 AbstractStorage factory does not contain either backend

Scenario: explicit registration works

  • WHEN registerIndexedDBStorage() and registerLocalStorageStorage() 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 batch includes 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 through EncryptedStorage and then get(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 through CompressedStorage with 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 StorageEvent with type: "set" and the same key

Scenario: delete emits clear event

  • WHEN clear() is called in tab A
  • THEN tab B receives a StorageEvent with type: "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 calls set(key, data)
  • THEN the iterator yields a StorageEvent with the new data

Scenario: iterator completes on abort

  • WHEN the AbortSignal passed 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 matching updatedAt
  • THEN the write succeeds and updatedAt is 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 SuaveplanError with code STORAGE_QUOTA_EXCEEDED is 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"} and storage.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_total is 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"