# Capability Spec: storage-browser-indexeddb **Package**: `@suaveplan/storage-browser-indexeddb` **Status**: ACTIVE ## Requirements ### Requirement: AbstractStorage conformance `IndexedDbStorage` SHALL extend `AbstractStorage` and implement every abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getMetadata`, `getWithMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`) with the public-contract semantics each method declares in `@suaveplan/storage`. #### Scenario: instance shape - **WHEN** a consumer constructs `new IndexedDbStorage()` - **THEN** the instance passes `instanceof AbstractStorage` AND every documented method is a function on the instance #### Scenario: CRUD round-trip - **WHEN** the consumer calls `set(key, bytes)` followed by `get(key)` - **THEN** the returned `Uint8Array` byte-equals the input **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"round-trips Uint8Array values"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"is an AbstractStorage instance"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"implements every AbstractStorage abstract method"` --- ### Requirement: Capabilities matrix `IndexedDbStorage.capabilities` SHALL be the frozen object `{ partialReads: true, partialWrites: false, nativeStreams: false, atomicWrites: true, contentAddressable: false }`. #### Scenario: declared capabilities - **WHEN** consumers read `storage.capabilities` - **THEN** the value deep-equals the documented matrix **Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"` --- ### Requirement: Strict key sanitisation The package SHALL validate every caller-supplied key via `sanitizeKey` BEFORE any IndexedDB call. Rejections SHALL throw `IndexedDbError(INDEXEDDB.INVALID_KEY)` for empty strings, control characters, absolute paths, and `..` traversal segments, and `IndexedDbError(INDEXEDDB.KEY_TOO_LONG)` for keys over 1024 characters. #### Scenario: path-traversal rejection - **WHEN** a consumer calls `storage.get("../../etc/passwd")` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_KEY` #### Scenario: oversize key rejection - **WHEN** a consumer calls `storage.get("a".repeat(1025))` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.KEY_TOO_LONG` **Verified by:** `src/key-sanitizer/key-sanitizer.test.ts::"rejects path-traversal segments"` AND `src/key-sanitizer/key-sanitizer.test.ts::"rejects keys over MAX_KEY_LENGTH"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"every INDEXEDDB code is thrown by at least one production path"` --- ### Requirement: Zod-validated options boundary Constructing `IndexedDbStorage` SHALL throw `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` when the supplied options fail the `IndexedDbStorageOptionsSchema` Zod check. The thrown error SHALL carry the Zod error as `cause`. #### Scenario: bad option type - **WHEN** a consumer passes `new IndexedDbStorage({ dbName: 123 as never })` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_OPTIONS` #### Scenario: empty dbName - **WHEN** a consumer passes `new IndexedDbStorage({ dbName: "" })` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_OPTIONS` **Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"rejects malformed options with INDEXEDDB.INVALID_OPTIONS"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"rejects empty dbName option with INDEXEDDB.INVALID_OPTIONS"` --- ### Requirement: AbortSignal support Every public async method SHALL accept an optional `AbortSignal` as the final parameter. A pre-aborted signal SHALL cause the method to throw `IndexedDbError(INDEXEDDB.ABORTED)` BEFORE any IndexedDB call is issued. #### Scenario: pre-aborted signal short-circuit - **WHEN** a consumer calls any public method with an already-aborted signal - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.ABORTED` **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"rejects with INDEXEDDB.ABORTED when signal is pre-aborted"` --- ### Requirement: TTL via `expiresAt` field When `set(key, bytes, { ttl })` is called with `ttl > 0`, the persisted row SHALL carry `expiresAt = now + ttl * 1000`. Read paths (`get`, `getMetadata`, `getWithMetadata`, `getPartial`, `exists`, `list`, `getSize`) SHALL skip entries whose `expiresAt` has passed; `get` / `getWithMetadata` / `getPartial` SHALL lazily delete the expired row. #### Scenario: expired entry read returns null/undefined - **WHEN** a consumer reads a key whose `expiresAt` is in the past - **THEN** `get` returns `undefined`, `getMetadata` returns `null`, `exists` returns `false` #### Scenario: live entry read returns value - **WHEN** a consumer reads a key whose `expiresAt` is in the future - **THEN** `get` returns the stored bytes **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"expires entries after TTL"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"does not expire entries without TTL"` --- ### Requirement: Per-entry metadata Every `set` SHALL record `{ size, createdAt, modifiedAt, metadata? }` on the row. `createdAt` and `modifiedAt` SHALL use the injected `Clock` when supplied; the default is `Date.now()`. `getMetadata` / `getWithMetadata` SHALL surface this record. #### Scenario: metadata round-trip - **WHEN** a consumer calls `set(key, bytes, { metadata: { mimeType: "text/plain" } })` - **THEN** `getMetadata(key)` returns a record with `size === bytes.byteLength`, numeric `createdAt`/`modifiedAt`, and `metadata.mimeType === "text/plain"` **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"records size, createdAt, modifiedAt, and custom metadata"` --- ### Requirement: Prefix-based listing with pagination `list(options?)` SHALL return only live keys, sorted ascending. When `options.prefix` is supplied, only keys starting with that prefix are returned. When `options.limit` is supplied, the result is paginated; `hasMore` reflects whether more keys remain past `offset + limit`. `total` SHALL be the unfiltered prefix-matched count. #### Scenario: prefix filter - **WHEN** the store contains `user/1`, `user/2`, `admin/1` and the consumer calls `list({ prefix: "user/" })` - **THEN** the result `keys` are exactly `["user/1", "user/2"]` #### Scenario: pagination - **WHEN** the store contains 3 keys and the consumer calls `list({ limit: 2, offset: 0 })` - **THEN** the result `keys.length === 2` and `hasMore === true` **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"lists keys with prefix + pagination"` --- ### Requirement: Partial reads `getPartial(key, offset, length, signal?)` SHALL return the requested byte range from the stored value, clamped to the value's byte length, or `null` when the key is absent / expired. Negative or non-integer `offset`/`length` SHALL throw `IndexedDbError(INDEXEDDB.INVALID_KEY)`. #### Scenario: in-bounds slice - **WHEN** the consumer calls `getPartial(key, 1, 2)` on a 5-byte value - **THEN** the returned `Uint8Array` byte-equals bytes `[1..3)` of the value #### Scenario: negative offset rejection - **WHEN** the consumer calls `getPartial(key, -1, 1)` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_KEY` **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"returns the requested byte range from getPartial"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"rejects negative offset on getPartial"` --- ### Requirement: DOMException mapping The package SHALL translate every IndexedDB DOMException at the boundary into a typed `IndexedDbError`: - `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `INDEXEDDB.QUOTA_EXCEEDED` - `NotAllowedError` → `INDEXEDDB.PERMISSION_DENIED` - `VersionError` → `INDEXEDDB.VERSION_ERROR` - `AbortError` → `INDEXEDDB.ABORTED` - `TransactionInactiveError` → `INDEXEDDB.TRANSACTION_ABORTED` - Unknown errors → `INDEXEDDB.IO_FAILED` with the original error preserved as `cause`. #### Scenario: quota error mapping - **WHEN** an underlying `put` rejects with `QuotaExceededError` - **THEN** `set` throws `IndexedDbError` with `code === INDEXEDDB.QUOTA_EXCEEDED` and the original error attached as `cause` #### Scenario: unknown error mapping - **WHEN** an underlying call rejects with a generic `Error` - **THEN** the package throws `IndexedDbError` with `code === INDEXEDDB.IO_FAILED` **Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"maps QuotaExceededError to INDEXEDDB.QUOTA_EXCEEDED"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"maps unknown errors to INDEXEDDB.IO_FAILED preserving cause"` --- ### Requirement: Lifecycle dedup on open Concurrent first-touch operations on a fresh `IndexedDbStorage` SHALL share a single in-flight `IDBOpenDBRequest` promise; only one database open SHALL be performed regardless of caller concurrency. #### Scenario: concurrent first-touch opens once - **WHEN** ten `get(key)` calls fan out concurrently before the database has opened - **THEN** the underlying `IdbFactoryLike.open` is invoked exactly once **Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"concurrent first-touch shares a single open promise"` --- ### Requirement: OpenTelemetry surface Every public operation SHALL emit one `op_duration_ms{op,outcome}` histogram sample, increment exactly one of `{reads,writes,deletes}_total{op,outcome}` counters on success, increment `errors_total{op,outcome}` on `error`/`aborted`, and (where bytes flow) record one `io_bytes{op,direction}` histogram sample. The instrument set SHALL respect a caller-installed `MeterProvider`. #### Scenario: in-memory MeterProvider receives observations - **WHEN** a consumer installs an in-memory `MeterProvider` and performs `set(key, bytes)` - **THEN** the provider's recorder receives one `writes_total` counter increment, one `op_duration_ms` sample, and one `io_bytes{direction: "write"}` sample **Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"emits OTel instruments through an in-memory MeterProvider"` AND `src/otel/otel.test.ts::"records exactly one counter per successful operation"` --- ### Requirement: Factory registration is explicit and idempotent `registerIndexedDbProvider()` SHALL register `IndexedDbStorage` under the `"indexeddb"` slot in `@suaveplan/storage/factory`. Repeated calls SHALL be no-ops. The package SHALL NOT self-register at module-evaluation time. #### Scenario: round-trip via createStorage - **WHEN** a consumer calls `registerIndexedDbProvider()` then `createStorage({ type: "indexeddb", config })` - **THEN** the returned value is `instanceof IndexedDbStorage` #### Scenario: idempotent - **WHEN** a consumer calls `registerIndexedDbProvider()` three times in a row - **THEN** `hasStorageProvider("indexeddb")` remains `true` and no error is thrown **Verified by:** `src/registry/registry.test.ts::"registers the indexeddb provider and resolves it via createStorage"` AND `src/registry/registry.test.ts::"is idempotent across repeated registerIndexedDbProvider calls"` --- ### Requirement: Lazy default adapter `defaultIndexedDB()` SHALL return an `IdbFactoryLike` whose `open` method throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` lazily — only when invoked — when `globalThis.indexedDB` is missing. Module import SHALL NOT touch `globalThis.indexedDB`. #### Scenario: missing global throws on first call - **WHEN** the runtime environment does not expose `globalThis.indexedDB` and a consumer calls `defaultIndexedDB().open("db")` - **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.UNAVAILABLE` **Verified by:** `src/idb-adapter/idb-adapter.test.ts::"throws INDEXEDDB.UNAVAILABLE lazily when globalThis.indexedDB is missing"`