# 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` 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._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"`