Files
suaveplan/openspec/archive/2026-05-12-storage-browser-indexeddb/proposal.md
2026-05-27 23:29:53 +01:00

9.0 KiB

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 IDBOpenDBRequests 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.