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

89 lines
9.0 KiB
Markdown

# 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<IndexedDbErrorCode>` 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.