Skip to content

AppStorage and IndexedDB

源码版本v0.73.1

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

  1. Facade: AppStorage exposes four stores — settings, providerKeys, sessions, customProviders — plus backend, see packages/web-ui/src/storage/app-storage.ts:11-39.
  2. Global singleton: getAppStorage()/setAppStorage() is a module-level singleton; calling it before initialization throws, see packages/web-ui/src/storage/app-storage.ts:42-60.
  3. Store base class: the Store abstract class requires subclasses to implement getConfig() returning the IndexedDB schema, and accesses the underlying engine through setBackend/getBackend, see packages/web-ui/src/storage/store.ts:7-33.
  4. IndexedDB backend: IndexedDBStorageBackend implements the StorageBackend interface; on onupgradeneeded it creates object stores and indices per each store's config, see packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46.
  5. Session storage: SessionsStore uses two object stores: sessions (full messages) and sessions-metadata (lightweight metadata); save writes atomically across stores in a single transaction, see packages/web-ui/src/storage/stores/sessions-store.ts:30-35.
  6. Quota management: getQuotaInfo calls navigator.storage.estimate, requestPersistence calls navigator.storage.persist, see packages/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

AppStorage is structurally simple, just aggregating the stores:

typescript
// 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:

typescript
// 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:

typescript
// 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() before setAppStorage throws AppStorage not initialized, see packages/web-ui/src/storage/app-storage.ts:48-53.
  • Store without backend: Store.getBackend() throws Backend not set on <ClassName>, signaling a wiring error in the host, see packages/web-ui/src/storage/store.ts:27-32.
  • Quota detection unavailable: when navigator.storage?.estimate does not exist it returns {usage:0, quota:0, percent:0} instead of throwing, see packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185.
  • persist request denied: when navigator.storage.persist returns false, requestPersistence returns 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); otherwise store.put(value, key). SettingsStore and ProviderKeysStore both use out-of-line keys with no keyPath, see packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73.
  • Version upgrade: bumping IndexedDBConfig.version triggers onupgradeneeded, 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.