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>
192 lines
7.9 KiB
Markdown
192 lines
7.9 KiB
Markdown
# 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<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:
|
||
|
||
```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<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:
|
||
|
||
```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.
|