# Feature Request — `@shade/storage-indexeddb` **To**: Shade SDK team **From**: Dispatch (browser-based Shade consumer) **Target**: Shade SDK 4.3.x (or whichever release vehicle fits) **Priority**: blocks all browser-based Shade apps from achieving session persistence across tab refresh --- ## Summary Ship an official IndexedDB-backed `StorageProvider` adapter as a new workspace package `@shade/storage-indexeddb`, so browser-based Shade SDK consumers can persist identity, prekeys, sessions, and peer-verification state across tab refresh and browser restart — the same way `@shade/storage-sqlite` does for Node and `@shade/storage-postgres` does for server deployments. ## Problem Today the Shade SDK ships three storage paths: | spec | adapter | environment | | --------------------------------- | ------------------------ | ---------------- | | `"memory"` | `MemoryStorage` (in-SDK) | tests, ephemeral | | `"sqlite:/path"` | `@shade/storage-sqlite` | Node | | `{ type: 'postgres', url: '…' }` | `@shade/storage-postgres`| Node servers | There is **no browser-storage option**. The only way to run Shade in a browser today is `storage: "memory"`, which means: - Identity keypair regenerates on every page load - Sessions reset → re-enrollment after every refresh - `getLocalRegistrationId()` returns a fresh value → `device:${id}` address changes → server-side device record orphaned every reload This forces every browser-based Shade app to either (a) accept the broken UX, or (b) build their own `StorageProvider` from scratch — duplicating ~25 methods × N consumers, with no shared conformance test surface. The right place to solve this is at the SDK level, exactly mirroring how SQLite and Postgres are handled. ## Proposed package `packages/shade-storage-indexeddb/` — modeled directly after `packages/shade-storage-sqlite/`. Same package shape, same test layout, same `@shade/core`-only runtime dependency surface. ### Public API ```ts // @shade/storage-indexeddb export class IndexedDBStorage implements StorageProvider { /** * Open (or create) the IndexedDB database. Idempotent — repeated calls * with the same dbName return a connection sharing the same object stores. */ static async create(opts?: { dbName?: string }): Promise; /** * Cleanly close the underlying connection. Future calls will reopen. * Called by Shade.shutdown() when consumers register cleanup. */ async close(): Promise; // ─── all StorageProvider methods (identity, prekeys, sessions, // retired identities, peer verifications, optional stream-state) ─── } ``` `dbName` defaults to something like `"shade"`. Consumers like Dispatch will pass distinct names per app (`"dispatch-dashboard-shade"`, `"dispatch-host-ui-shade"`) so DevTools' IndexedDB inspector groups them sensibly, even though origin-isolation already makes the data isolated. ### SDK integration `@shade/sdk` `resolveStorage()` gets a fourth branch: ```ts if (typeof spec === 'object' && spec.type === 'indexeddb') { const moduleId = '@shade/storage-indexeddb'; const mod = (await import(moduleId)) as { IndexedDBStorage: { create(opts: { dbName?: string }): Promise }; }; return mod.IndexedDBStorage.create({ dbName: spec.dbName }); } ``` Dynamic import keeps `@shade/storage-indexeddb` an optional dependency, matching the Postgres pattern — Node-only consumers don't need to install a browser-only adapter. Consumer surface: ```ts const shade = await createShade({ prekeyServer: 'https://…/shade-prekey', storage: { type: 'indexeddb', dbName: 'my-app-shade' }, address: 'device:user@example.com', // optional — falls back to device:${registrationId} }); ``` ## Implementation guidance (non-prescriptive) - **IDB wrapper**: suggest `idb` (Jake Archibald's thin wrapper, well-typed, zero deps). Avoid Dexie or idb-keyval — we want full schema control to match the SQL adapters' explicit schemas. - **Object-store layout**: one store per StorageProvider category (`identity`, `signedPreKeys`, `oneTimePreKeys`, `sessions`, `trustedIdentities`, `retiredIdentities`, `peerVerifications`, `streamStates`). Keypaths match the natural keys (`keyId`, `address`, `streamId`). - **Schema version**: integer, bumped on every shape change. Migrations in `db.upgrade(...)` callback. Document schema-history alongside the SQLite schema. - **Concurrency**: IndexedDB transactions are auto-committing — the adapter must keep operations within a single transaction where SQL adapters do. Particular care for `bumpPeerIdentityVersion` (atomic read-modify-write). - **Stream-state methods**: implement them. Browser apps will increasingly use `@shade/transfer` for large file resume, and parity with SQLite's capabilities matters. ## Test expectations Mirror `packages/shade-storage-sqlite/tests/`: - `indexeddb-storage.test.ts` — full StorageProvider surface (identity, sessions, trusted identities, retired identities) - `indexeddb-prekey-store.test.ts` — signed + one-time prekey lifecycle - `peer-verifications.test.ts` — verification CRUD + identity-version bumping invariants - `indexeddb-stream-state.test.ts` (if stream-state is implemented) Use **`fake-indexeddb`** for the Node test environment — it's the established standard, supports the v3 spec, and lets us run IDB tests in `bun test` / `vitest` / `jest` without a real browser. If/when Shade gains a shared `StorageProvider` conformance test suite, this adapter should consume it directly. Until then, follow the SQLite adapter's per-method coverage style. ## Acceptance criteria 1. `@shade/storage-indexeddb` published at version 4.3.0 (or whichever matches the next Shade release) 2. `@shade/sdk` `resolveStorage()` resolves `{ type: 'indexeddb', dbName? }` via dynamic import 3. Full StorageProvider conformance in tests (identity, prekeys, sessions, retired identities, peer verifications, stream-state) 4. Documented in Shade docs alongside SQLite/Postgres adapters 5. README example showing browser-app integration 6. Bundle-size note: dynamic-imported IDB module shouldn't pull crypto dependencies — adapter should be ≤ ~10 KB minified+gzipped ## Out of scope (deferred) - **Encryption-at-rest** for the IDB contents — separate work item; should match the deviceKey-AES-GCM pattern Shade already uses for `secretEnc` in `PersistedStreamState`. This adapter ships unencrypted-at-rest in v1 (consistent with SQLite), with the encryption layer added uniformly to all adapters later. - **Cross-tab BroadcastChannel sync** — IDB is shared across same-origin tabs already; concurrent writes work via IDB transactions. Real-time notification across tabs (e.g. "session was rotated in another tab") is a separate concern, not storage-adapter scope. - **Quota handling** — IDB quota for a Shade keystore is far below realistic browser quotas. If it ever becomes relevant, add a `QuotaExceededError` observability hook then. ## Why this can't be done in consumer-land Building this in Dispatch (or any other consumer) would mean: - 25+ method `StorageProvider` re-implementation per consumer - No shared conformance tests - Schema drift across consumers — each would invent its own object-store shape, blocking any future cross-app data import/export - Every Shade SDK update that adjusts `StorageProvider` would force every consumer to track and patch independently `@shade/storage-sqlite` and `@shade/storage-postgres` are part of the SDK for the same reason. IndexedDB belongs alongside them. ## What unblocks Shipping this unblocks Dispatch's "Slice 2.5" — persistent enrollment across browser refresh, which today is the largest QA-friction point in the dev loop. Any future browser-Shade consumer (web dashboard, contact-list app, browser-extension messenger) gets persistence for free.