AppStorage and IndexedDB
pi-web-ui funnels every persistence need through a single AppStorage facade: provider API keys, user settings, session history, custom providers. The underlying engine is IndexedDB, abstracted through the StorageBackend interface so it can be swapped for a remote storage. Each store declares its own IndexedDB schema (config + indices), and AppStorage wires these stores to the backend at construction, exposing the singleton getAppStorage() for global access.
Responsibilities
- Facade:
AppStorageexposes four stores —settings,providerKeys,sessions,customProviders— plusbackend, seepackages/web-ui/src/storage/app-storage.ts:11-39. - Global singleton:
getAppStorage()/setAppStorage()is a module-level singleton; calling it before initialization throws, seepackages/web-ui/src/storage/app-storage.ts:42-60. - Store base class: the
Storeabstract class requires subclasses to implementgetConfig()returning the IndexedDB schema, and accesses the underlying engine throughsetBackend/getBackend, seepackages/web-ui/src/storage/store.ts:7-33. - IndexedDB backend:
IndexedDBStorageBackendimplements theStorageBackendinterface; ononupgradeneededit creates object stores and indices per each store's config, seepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46. - Session storage:
SessionsStoreuses two object stores:sessions(full messages) andsessions-metadata(lightweight metadata);savewrites atomically across stores in a single transaction, seepackages/web-ui/src/storage/stores/sessions-store.ts:30-35. - Quota management:
getQuotaInfocallsnavigator.storage.estimate,requestPersistencecallsnavigator.storage.persist, seepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192.
Design motivation
Why not localStorage? Three reasons: first, data like API keys and session history can exceed localStorage's 5 MB cap; second, IndexedDB supports indices — the lastModified index on sessions-metadata lets getAllMetadata return entries in reverse time order directly, without a full scan; third, navigator.storage.estimate and persist let the app observe quota and request persistence, so the browser does not evict data under pressure.
Why does SessionsStore split into two stores, sessions and sessions-metadata? Because the session list page only needs lightweight fields — title, time, message count, preview — and does not need to pull the full content of every message. Metadata is stored separately; the list page calls getAllFromIndex("sessions-metadata", "lastModified", "desc") once to fetch everything, and only when the user opens a specific session does it get("sessions", id) to read the full data. This is a classic list/detail split.
Why is the backend an interface instead of using IndexedDB directly? Because pi-web-ui also runs in extension and remote scenarios; once StorageBackend is abstracted, the host can pass a backend implemented against a remote API, and the store code does not change at all. The Store base class does not know whether the underlying engine is IndexedDB or HTTP — it just grabs an object matching the interface through getBackend().
Key files
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage, four store fields plus backend.packages/web-ui/src/storage/app-storage.ts:48-60—getAppStorage/setAppStorageglobal singleton.packages/web-ui/src/storage/store.ts:7-33—Storeabstract base class,getConfig/setBackend/getBackend.packages/web-ui/src/storage/types.ts:29-88—StorageBackendinterface andStorageTransaction.packages/web-ui/src/storage/types.ts:94-168—SessionMetadataandSessionDatatypes, defining list vs detail fields.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDBandonupgradeneeded, creating stores and indices per config.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex, traversing in index order with a cursor.packages/web-ui/src/storage/stores/sessions-store.ts:30-55—save/deletecross-store transaction,getAllMetadatauses thelastModifiedindex.packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore, uses out-of-line keys with no keyPath.packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore, stores keys by provider name.packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore, for user-defined LLM providers.
AppStorage is structurally simple, just aggregating the stores:
// packages/web-ui/src/storage/app-storage.ts:11-30
export class AppStorage {
readonly backend: StorageBackend;
readonly settings: SettingsStore;
readonly providerKeys: ProviderKeysStore;
readonly sessions: SessionsStore;
readonly customProviders: CustomProvidersStore;
constructor(
settings: SettingsStore,
providerKeys: ProviderKeysStore,
sessions: SessionsStore,
customProviders: CustomProvidersStore,
backend: StorageBackend,
) {
this.settings = settings;
this.providerKeys = providerKeys;
this.sessions = sessions;
this.customProviders = customProviders;
this.backend = backend;
}
}SessionsStore.save uses a cross-store transaction so metadata and full data stay consistent:
// packages/web-ui/src/storage/stores/sessions-store.ts:30-35
async save(data: SessionData, metadata: SessionMetadata): Promise<void> {
await this.getBackend().transaction(["sessions", "sessions-metadata"], "readwrite", async (tx) => {
await tx.set("sessions", data.id, data);
await tx.set("sessions-metadata", metadata.id, metadata);
});
}IndexedDBStorageBackend.getDB creates stores and indices per each store's config in onupgradeneeded:
// packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:23-40
request.onupgradeneeded = (_event) => {
const db = request.result;
for (const storeConfig of this.config.stores) {
if (!db.objectStoreNames.contains(storeConfig.name)) {
const store = db.createObjectStore(storeConfig.name, {
keyPath: storeConfig.keyPath,
autoIncrement: storeConfig.autoIncrement,
});
if (storeConfig.indices) {
for (const indexConfig of storeConfig.indices) {
store.createIndex(indexConfig.name, indexConfig.keyPath, {
unique: indexConfig.unique,
});
}
}
}
}
};Data flow
When writing a session, AppStorage.sessions.save writes atomically across two stores; reading the session list only hits the metadata store:
Edges and failures
- Not initialized: calling
getAppStorage()beforesetAppStoragethrowsAppStorage not initialized, seepackages/web-ui/src/storage/app-storage.ts:48-53. - Store without backend:
Store.getBackend()throwsBackend not set on <ClassName>, signaling a wiring error in the host, seepackages/web-ui/src/storage/store.ts:27-32. - Quota detection unavailable: when
navigator.storage?.estimatedoes not exist it returns{usage:0, quota:0, percent:0}instead of throwing, seepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185. - persist request denied: when
navigator.storage.persistreturns false,requestPersistencereturns false; data may be evicted by the browser, and the host should warn the user. - Out-of-line key vs keyPath: on
set, if the store has a keyPath,store.put(value); otherwisestore.put(value, key).SettingsStoreandProviderKeysStoreboth use out-of-line keys with no keyPath, seepackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73. - Version upgrade: bumping
IndexedDBConfig.versiontriggersonupgradeneeded, but the code only creates stores that do not yet exist — it does not handle field migration; the host must extend that itself when changing schema.
Summary
AppStorage is a facade backed by the StorageBackend interface (default IndexedDBStorageBackend); each Store subclass declares its own IndexedDB schema. Sessions use the sessions + sessions-metadata dual-store split for list/detail separation. AgentInterface reads provider keys and proxy settings through getAppStorage() — these two consumers are covered in AgentInterface session host and CORS proxy and createStreamFn.