Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).
- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
in a single readwrite IDB transaction (atomic, vs SQLite's
read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
dynamic import (lazy, optional dep — same pattern as
@shade/storage-postgres). Named StorageSpec type now reused by
ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
surface + peer-verifications + full E2EE conversation surviving a
simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.9 KiB
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
// @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<IndexedDBStorage>;
/**
* Cleanly close the underlying connection. Future calls will reopen.
* Called by Shade.shutdown() when consumers register cleanup.
*/
async close(): Promise<void>;
// ─── 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:
if (typeof spec === 'object' && spec.type === 'indexeddb') {
const moduleId = '@shade/storage-indexeddb';
const mod = (await import(moduleId)) as {
IndexedDBStorage: { create(opts: { dbName?: string }): Promise<StorageProvider> };
};
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:
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/transferfor 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 lifecyclepeer-verifications.test.ts— verification CRUD + identity-version bumping invariantsindexeddb-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
@shade/storage-indexeddbpublished at version 4.3.0 (or whichever matches the next Shade release)@shade/sdkresolveStorage()resolves{ type: 'indexeddb', dbName? }via dynamic import- Full StorageProvider conformance in tests (identity, prekeys, sessions, retired identities, peer verifications, stream-state)
- Documented in Shade docs alongside SQLite/Postgres adapters
- README example showing browser-app integration
- 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
secretEncinPersistedStreamState. 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
QuotaExceededErrorobservability hook then.
Why this can't be done in consumer-land
Building this in Dispatch (or any other consumer) would mean:
- 25+ method
StorageProviderre-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
StorageProviderwould 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.