Files
Shade/docs/shade-storage-indexeddb.md
Sterister f5f42fe557 release(v4.3.0): browser persistence via @shade/storage-indexeddb
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>
2026-05-05 17:35:02 +02:00

7.9 KiB
Raw Blame History

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