Skip to content

AppStorage e IndexedDB

源码版本v0.73.1

pi-web-ui mete todas las necesidades de persistencia en una fachada AppStorage: API keys de providers, settings de usuario, historial de sesiones, providers personalizados. La base es IndexedDB, abstraída con la interfaz StorageBackend y reemplazable por almacenamiento remoto. Cada store declara su propio schema de IndexedDB (config + indices); AppStorage en el constructor conecta los stores con el backend y ofrece el singleton getAppStorage() para acceso global.

Responsabilidades

  1. Fachada: AppStorage expone cuatro stores settings, providerKeys, sessions, customProviders y el backend. Ver packages/web-ui/src/storage/app-storage.ts:11-39.
  2. Singleton global: getAppStorage()/setAppStorage() a nivel de módulo; sin inicializar lanza. Ver packages/web-ui/src/storage/app-storage.ts:42-60.
  3. Store base: la clase abstracta Store exige a las subclases implementar getConfig() que devuelve el schema IndexedDB; accede al backend vía setBackend/getBackend. Ver packages/web-ui/src/storage/store.ts:7-33.
  4. Backend IndexedDB: IndexedDBStorageBackend implementa la interfaz StorageBackend; en onupgradeneeded crea los object stores e indices según el config de cada store. Ver packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46.
  5. Almacenamiento de sesiones: SessionsStore usa dos object stores: sessions (mensajes completos) y sessions-metadata (metadata ligera); save escribe atómicamente entre stores en una transacción. Ver packages/web-ui/src/storage/stores/sessions-store.ts:30-35.
  6. Gestión de cuota: getQuotaInfo invoca navigator.storage.estimate; requestPersistence invoca navigator.storage.persist. Ver packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192.

Motivación de diseño

¿Por qué no usar localStorage? Tres razones. Una, API keys e historial de sesiones pueden superar el límite de 5MB de localStorage. Dos, IndexedDB soporta índices: el índice lastModified de sessions-metadata permite que getAllMetadata devuelva directamente en orden cronológico inverso sin escanear todo. Tres, navigator.storage.estimate y persist permiten a la app enterarse de la cuota y pedir persistencia, evitando que el navegador borre datos bajo presión.

¿Por qué SessionsStore se separa en sessions y sessions-metadata? Porque la página de listado de sesiones sólo necesita campos ligeros (título, tiempo, número de mensajes, preview), no el content completo de cada mensaje. La metadata se guarda aparte; el listado usa getAllFromIndex("sessions-metadata", "lastModified", "desc") y obtiene todo de una vez; sólo cuando el usuario abre una sesión concreta se hace get("sessions", id) para el contenido completo. Es el típico patrón lista/detalle.

¿Por qué el backend es una interfaz y no IndexedDB directo? Porque pi-web-ui también se usa en extensiones y escenarios remotos; con StorageBackend abstraído, el host puede pasar un backend implementado sobre una API remota sin tocar el código de los stores. La clase base Store no sabe si debajo hay IndexedDB o HTTP; sólo obtiene a través de getBackend() un objeto que cumple la interfaz.

Archivos clave

La estructura de AppStorage es simple, sólo agrega los 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 usa una transacción entre stores para garantizar consistencia entre metadata y datos completos:

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 crea store e indices en onupgradeneeded según el config de cada store:

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,
                    });
                }
            }
        }
    }
};

Flujo de datos

Al guardar una sesión, AppStorage.sessions.save escribe atómicamente entre dos stores; el listado sólo consulta el store de metadata:

Límites y fallos

  • Sin inicializar: si se llama getAppStorage() antes que setAppStorage, lanza AppStorage not initialized. Ver packages/web-ui/src/storage/app-storage.ts:48-53.
  • Store sin backend: Store.getBackend() lanza Backend not set on <ClassName>, señal de que el ensamblaje del host está mal. Ver packages/web-ui/src/storage/store.ts:27-32.
  • Detección de cuota no disponible: si navigator.storage?.estimate no existe, devuelve {usage:0, quota:0, percent:0}, sin error. Ver packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185.
  • Persist denegada: si navigator.storage.persist devuelve false, requestPersistence devuelve false; los datos pueden ser limpiados por el navegador y el host debería avisar al usuario.
  • Distinción out-of-line key vs keyPath: en set, si el store tiene keyPath se hace store.put(value); si no, store.put(value, key). SettingsStore y ProviderKeysStore son sin keyPath, usan out-of-line key. Ver packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73.
  • Upgrade de versión: al subir IndexedDBConfig.version se dispara onupgradeneeded, pero el código sólo crea stores inexistentes, no migra campos; el host debe extender el schema por su cuenta.

Resumen

AppStorage es una fachada; debajo está la interfaz StorageBackend (por defecto IndexedDBStorageBackend), y cada subclase de Store declara su schema IndexedDB. Las sesiones usan doble store sessions + sessions-metadata para separar lista y detalle. AgentInterface lee provider key y proxy settings a través de getAppStorage(); ver host de sesión AgentInterface y proxy CORS y createStreamFn.