AppStorage e IndexedDB
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
- Fachada:
AppStorageexpone cuatro storessettings,providerKeys,sessions,customProvidersy elbackend. Verpackages/web-ui/src/storage/app-storage.ts:11-39. - Singleton global:
getAppStorage()/setAppStorage()a nivel de módulo; sin inicializar lanza. Verpackages/web-ui/src/storage/app-storage.ts:42-60. - Store base: la clase abstracta
Storeexige a las subclases implementargetConfig()que devuelve el schema IndexedDB; accede al backend víasetBackend/getBackend. Verpackages/web-ui/src/storage/store.ts:7-33. - Backend IndexedDB:
IndexedDBStorageBackendimplementa la interfazStorageBackend; enonupgradeneededcrea los object stores e indices según el config de cada store. Verpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46. - Almacenamiento de sesiones:
SessionsStoreusa dos object stores:sessions(mensajes completos) ysessions-metadata(metadata ligera);saveescribe atómicamente entre stores en una transacción. Verpackages/web-ui/src/storage/stores/sessions-store.ts:30-35. - Gestión de cuota:
getQuotaInfoinvocanavigator.storage.estimate;requestPersistenceinvocanavigator.storage.persist. Verpackages/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
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage, cuatro campos de store más backend.packages/web-ui/src/storage/app-storage.ts:48-60— Singleton globalgetAppStorage/setAppStorage.packages/web-ui/src/storage/store.ts:7-33— Clase abstractaStore,getConfig/setBackend/getBackend.packages/web-ui/src/storage/types.ts:29-88— InterfazStorageBackendyStorageTransaction.packages/web-ui/src/storage/types.ts:94-168— TiposSessionMetadataySessionData, definen campos de lista vs detalle.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDByonupgradeneeded, crea stores e indices según config.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex, recorre con cursor por orden de indice.packages/web-ui/src/storage/stores/sessions-store.ts:30-55—save/deletecon transacción entre stores;getAllMetadatausa indicelastModified.packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore, sin keyPath, usa out-of-line key.packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore, guarda key por nombre de provider.packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore, providers LLM definidos por el usuario.
La estructura de AppStorage es simple, sólo agrega los 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 usa una transacción entre stores para garantizar consistencia entre metadata y datos completos:
// 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:
// 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 quesetAppStorage, lanzaAppStorage not initialized. Verpackages/web-ui/src/storage/app-storage.ts:48-53. - Store sin backend:
Store.getBackend()lanzaBackend not set on <ClassName>, señal de que el ensamblaje del host está mal. Verpackages/web-ui/src/storage/store.ts:27-32. - Detección de cuota no disponible: si
navigator.storage?.estimateno existe, devuelve{usage:0, quota:0, percent:0}, sin error. Verpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185. - Persist denegada: si
navigator.storage.persistdevuelve false,requestPersistencedevuelve 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 hacestore.put(value); si no,store.put(value, key).SettingsStoreyProviderKeysStoreson sin keyPath, usan out-of-line key. Verpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73. - Upgrade de versión: al subir
IndexedDBConfig.versionse disparaonupgradeneeded, 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.