# Add @suaveplan/storage-browser-indexeddb — Browser IndexedDB AbstractStorage adapter **Package**: `@suaveplan/storage-browser-indexeddb` **Status**: PROPOSED **Type**: new-package ## Why Genesis ships `@suaveplan/storage` (AbstractStorage core with in-memory adapter), `@suaveplan/storage-s3` (object store), `@suaveplan/storage-file` (Node filesystem), and `@suaveplan/storage-browser-opfs` (browser OPFS). The upstream `../suaveplan/packages/browser/storage-browser` bundled IndexedDB AND LocalStorage in one package; on genesis we split them so each backend is independently tree-shakeable and consumers don't pay the bundle cost of the backend they didn't pick. This change ships the IndexedDB half. The upstream `IndexedDB` class pre-dates the genesis `AbstractStorage` contract: it uses the legacy abstract surface (no `getPartial` / `setPartial` / `createReadStream` / `createWriteStream` / `getItemSize`), has no AbortSignal, throws raw `StorageError` with stringly codes, side-effect registers in `index.ts`, and emits no OTel. This change rebuilds it fresh against the current `AbstractStorage` contract. ## What changes - ADDED: `IndexedDbStorage` class extending `AbstractStorage` — IndexedDB-backed key-value storage rooted at a configurable database + object-store name. - ADDED: `createIndexedDbStorage(options)` factory. - ADDED: `IndexedDbStorageOptions` interface + `IndexedDbStorageOptionsSchema` Zod schema; throws `INDEXEDDB.INVALID_OPTIONS` on malformed input. - ADDED: `IdbFactoryLike` structural surface (`{ open(name, version?): IdbOpenDbRequestLike }`) + `defaultIndexedDB()` adapter pointing at `globalThis.indexedDB`. Injectable so tests substitute a real `fake-indexeddb` factory without `vi.stubGlobal`. - ADDED: `sanitizeKey(key)` strict key boundary — same contract as the OPFS sibling (rejects empty, `..`, absolute paths, control characters, ≤ 1024 chars). - ADDED: `INDEXEDDB_CODES` registry via `defineCodes("INDEXEDDB", [...])` — 11 codes: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`. - ADDED: `IndexedDbError extends SuaveplanError` with `key` discriminant. - ADDED: Module-augmentation registry — `declare module "@suaveplan/storage/factory"` adds `indexeddb: IndexedDbStorage` to `StorageProviderRegistry` and `indexeddb: IndexedDbStorageOptions` to `StorageProviderConfig`; `registerIndexedDbProvider()` registers the constructor with the factory. - ADDED: TTL via stored `expiresAt` field + IndexedDB `expiresAt` index; expired rows are skipped on read and lazily deleted on first observation. - ADDED: `getPartial(offset, length)` via in-memory `Uint8Array.subarray` — declared `capabilities.partialReads: true`. - ADDED: DOMException mapping — `NotFoundError` → `undefined`/`false` per AbstractStorage contract; `NotAllowedError` → `INDEXEDDB.PERMISSION_DENIED`; `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `INDEXEDDB.QUOTA_EXCEEDED`; `VersionError` → `INDEXEDDB.VERSION_ERROR`; `AbortError` → `INDEXEDDB.ABORTED`; `TransactionInactiveError` → `INDEXEDDB.TRANSACTION_ABORTED`; missing `globalThis.indexedDB` → `INDEXEDDB.UNAVAILABLE`. - ADDED: OTel surface — `storage_browser_indexeddb.op_duration_ms{op,outcome}` + `storage_browser_indexeddb.io_bytes{op,direction}` histograms; `storage_browser_indexeddb.{reads,writes,deletes,errors}_total{op,outcome}` counters; per-op span on `@suaveplan/storage-browser-indexeddb` tracer. - ADDED: Lifecycle dedup — concurrent first-touch operations share a single in-flight `IDBOpenDBRequest` promise. ## Out of scope - Proactive TTL sweep via `requestIdleCallback` over the `expiresAt` index — the upstream enterprise plan flags it but the lazy-delete behavior already satisfies the AbstractStorage observable contract. A separate change can wire the sweep when a consumer needs it. - Cross-tab synchronisation via `BroadcastChannel` — orthogonal concern, handled at a higher layer. - Encryption-at-rest — handled by `@suaveplan/drive-plugin-chunks` and `@suaveplan/crypto-vault` for callers that need it. - LocalStorage backend — shipped as a sibling package `@suaveplan/storage-browser-localstorage` in a parallel change. - Structured-clone payload mode — upstream stored raw `Uint8Array`; we preserve that contract. A structured-clone variant can be a future feature. - Migration helper — version `1` is the only supported version at ship; future migrations add a `request.transaction.oldVersion` branch in `openDatabase`'s `onupgradeneeded` callback. ## Source mapping This change derives from `../suaveplan/enterprise/storage-browser.md` (120 lines covering BOTH IndexedDB and LocalStorage) and rebuilds `../suaveplan/packages/browser/storage-browser/src/indexeddb/indexeddb.ts` (413 lines) against the current `AbstractStorage` contract. | Requirement | Source lines | Notes | |---|---|---| | Browser IndexedDB adapter purpose | L10-L12 | "Browser storage adapters — IndexedDB and LocalStorage — that extend AbstractStorage … with a unified async API over Uint8Array payloads." — preserved for the IndexedDB half. | | Open/upgrade with `expiresAt` index | L21 | Implemented in `openDatabase` `onupgradeneeded` — `createObjectStore({ keyPath: "key" })` + `createIndex("expiresAt", "expiresAt", { unique: false })`. | | Metadata tracking (size, createdAt, modifiedAt, custom fields) | L12 | `IndexedDbEntry.metadata` carries `StorageMetadata` populated by `putEntry`. | | TTL expiration | L12 | `expiresAt` field on entry; read paths skip + lazy-delete on first observation. | | Prefix-based listing with pagination | L12 | `paginateKeys` over the cursor walker. | | Storage-quota error wrapping (`StorageQuotaError`) | L12 | Genesis swap: `INDEXEDDB.QUOTA_EXCEEDED` typed code; same observable contract. | | Factory registration | L23 | `registerIndexedDbProvider()` — explicit call, not side-effect import (deviation: see L48 below). | | `sideEffects` vs registry inconsistency fix | L48 | Genesis fix: explicit `registerIndexedDbProvider()` + `sideEffects: false` in `package.json`. | | Match documented sub-path exports | L49 | `package.json` `exports` map declares one entry per feature module. | | Co-located `.md` docs for every public symbol | L50 | Every `.ts` impl file ships with a sibling `.md`. | | Tests use `fake-indexeddb` | L19, L27 | Test suites use a real in-memory `fake-indexeddb` IDBFactory via the injectable adapter — no `vi.mock` / `vi.stubGlobal`. | | SSR / headless Node smoke import | L52 | Module-evaluation is side-effect-free; `defaultIndexedDB()` throws `UNAVAILABLE` lazily at first method call only. | | Bundle-size carve-out per backend | L53 | Split into separate genesis packages (this one + `@suaveplan/storage-browser-localstorage`). | ### Requirements with no source line | Requirement | Justification | |---|---| | AbortSignal threaded everywhere | Genesis-wide convention enforced by `AbstractStorage`; upstream pre-dates the AbortSignal contract. | | Strict key sanitiser (`sanitizeKey`) | Mirrors the `@suaveplan/storage-browser-opfs/key-sanitizer` boundary so consumers can swap backends without seeing a different validation contract. | | Zod boundary on `IndexedDbStorageOptions` | Genesis-wide convention; upstream had no runtime validation. | | OTel instruments (4 counters + 2 histograms) | Genesis-wide convention; upstream emitted no observability. | | Structured `IndexedDbError` with `key` discriminant | Genesis-wide convention; upstream used plain `StorageError` with stringly codes. | | Lifecycle dedup via in-flight open promise | Required to avoid duplicate `IDBOpenDBRequest`s under concurrent first-touch — upstream had a race. | | 11-code registry incl. `VERSION_ERROR` + `TRANSACTION_ABORTED` | Genesis-wide convention enforces complete DOMException coverage. | ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/storage-browser-indexeddb` to enumerate. No dependents (new package). - No dependents (verified by inspecting `MANIFEST.md` and the genesis dep graph). ## Verification approach - `src/indexeddb-storage/indexeddb-storage.test.ts` — covers CRUD round-trip, metadata, TTL expiry, prefix listing, pagination, partial reads, abort signals, DOMException mapping, lifecycle dedup, idempotent close. - `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts` — pins type-level / observability / strict-mode / lifecycle scenarios. - `src/registry/registry.test.ts` — registers + resolves via `createStorage`; idempotent re-registration; cleanup after `clearStorageProviders`. - `src/key-sanitizer/key-sanitizer.test.ts` — boundary rejections (empty, length, `..`, absolute paths, control chars). - `src/idb-adapter/idb-adapter.test.ts` — `isIndexedDbAvailable` truthy + falsy; lazy `UNAVAILABLE` throw. - `src/error-codes/error-codes.test.ts` — every code in the registry is constructable; `IndexedDbError.key` discriminant is preserved. - `src/otel/otel.test.ts` — counter dispatch per `op`; bytes histogram per direction; provider override + cache rebuild. ## Migration impact None — additive only.