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:
IndexedDbStorageclass extendingAbstractStorage— IndexedDB-backed key-value storage rooted at a configurable database + object-store name. - ADDED:
createIndexedDbStorage(options)factory. - ADDED:
IndexedDbStorageOptionsinterface +IndexedDbStorageOptionsSchemaZod schema; throwsINDEXEDDB.INVALID_OPTIONSon malformed input. - ADDED:
IdbFactoryLikestructural surface ({ open(name, version?): IdbOpenDbRequestLike }) +defaultIndexedDB()adapter pointing atglobalThis.indexedDB. Injectable so tests substitute a realfake-indexeddbfactory withoutvi.stubGlobal. - ADDED:
sanitizeKey(key)strict key boundary — same contract as the OPFS sibling (rejects empty,.., absolute paths, control characters, ≤ 1024 chars). - ADDED:
INDEXEDDB_CODESregistry viadefineCodes("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>withkeydiscriminant. - ADDED: Module-augmentation registry —
declare module "@suaveplan/storage/factory"addsindexeddb: IndexedDbStoragetoStorageProviderRegistryandindexeddb: IndexedDbStorageOptionstoStorageProviderConfig;registerIndexedDbProvider()registers the constructor with the factory. - ADDED: TTL via stored
expiresAtfield + IndexedDBexpiresAtindex; expired rows are skipped on read and lazily deleted on first observation. - ADDED:
getPartial(offset, length)via in-memoryUint8Array.subarray— declaredcapabilities.partialReads: true. - ADDED: DOMException mapping —
NotFoundError→undefined/falseper 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; missingglobalThis.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-indexeddbtracer. - ADDED: Lifecycle dedup — concurrent first-touch operations share a single in-flight
IDBOpenDBRequestpromise.
Out of scope
- Proactive TTL sweep via
requestIdleCallbackover theexpiresAtindex — 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-chunksand@suaveplan/crypto-vaultfor callers that need it. - LocalStorage backend — shipped as a sibling package
@suaveplan/storage-browser-localstoragein 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
1is the only supported version at ship; future migrations add arequest.transaction.oldVersionbranch inopenDatabase'sonupgradeneededcallback.
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.mdand 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 viacreateStorage; idempotent re-registration; cleanup afterclearStorageProviders.src/key-sanitizer/key-sanitizer.test.ts— boundary rejections (empty, length,.., absolute paths, control chars).src/idb-adapter/idb-adapter.test.ts—isIndexedDbAvailabletruthy + falsy; lazyUNAVAILABLEthrow.src/error-codes/error-codes.test.ts— every code in the registry is constructable;IndexedDbError.keydiscriminant is preserved.src/otel/otel.test.ts— counter dispatch perop; bytes histogram per direction; provider override + cache rebuild.
Migration impact
None — additive only.