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>
This commit is contained in:
2026-05-05 17:35:02 +02:00
parent b77b7e771c
commit f5f42fe557
37 changed files with 1167 additions and 54 deletions

View File

@@ -0,0 +1,191 @@
# 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.