AppStorage et IndexedDB
pi-web-ui rassemble tous les besoins de persistance derrière une façade AppStorage : clés API par provider, réglages utilisateur, historique des sessions, providers personnalisés. En dessous se trouve IndexedDB, abstrait via une interface StorageBackend qu'on peut remplacer par un stockage distant. Chaque store déclare son propre schéma IndexedDB (config + indices) ; AppStorage connecte ces stores au backend à la construction et expose un singleton getAppStorage() pour l'accès global.
Responsabilités
- Façade :
AppStorageexpose quatre stores —settings,providerKeys,sessions,customProviders— plusbackend, voirpackages/web-ui/src/storage/app-storage.ts:11-39. - Singleton global :
getAppStorage()/setAppStorage()sont des singletons au niveau module ; une erreur est levée si non initialisé, voirpackages/web-ui/src/storage/app-storage.ts:42-60. - Classe de base Store : la classe abstraite
Storedemande aux sous-classes d'implémentergetConfig()pour renvoyer le schéma IndexedDB ; on accède au backend viasetBackend/getBackend, voirpackages/web-ui/src/storage/store.ts:7-33. - Backend IndexedDB :
IndexedDBStorageBackendimplémente l'interfaceStorageBackend; suronupgradeneeded, il crée les object stores et index selon la config de chaque store, voirpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46. - Stockage des sessions :
SessionsStoreutilise deux object stores —sessions(messages complets) etsessions-metadata(métadonnées légères) ;saveécrit de façon atomique dans une transaction inter-store, voirpackages/web-ui/src/storage/stores/sessions-store.ts:30-35. - Gestion du quota :
getQuotaInfoappellenavigator.storage.estimate,requestPersistenceappellenavigator.storage.persist, voirpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192.
Motivations de design
Pourquoi ne pas utiliser localStorage ? Trois raisons : d'abord, les données comme les clés API ou l'historique des sessions peuvent dépasser la limite de 5 Mo de localStorage ; ensuite, IndexedDB supporte les index, et l'index lastModified de sessions-metadata permet à getAllMetadata de renvoyer directement trié par ordre chronologique inverse, sans tout scanner ; enfin, navigator.storage.estimate et persist permettent à l'application de connaître le quota et de demander la persistance, pour éviter que le navigateur ne purge les données sous pression.
Pourquoi SessionsStore sépare-t-il sessions et sessions-metadata en deux stores ? Parce que la liste des sessions n'a besoin que des champs légers — titre, horodatage, nombre de messages, aperçu — sans devoir tirer tout le contenu de chaque message. Les métadonnées sont stockées à part : la liste se charge via getAllFromIndex("sessions-metadata", "lastModified", "desc") en un seul appel ; ce n'est qu'à l'ouverture d'une session spécifique qu'on lit le contenu intégral avec get("sessions", id). C'est la séparation classique liste / détail.
Pourquoi le backend est-il une interface plutôt qu'IndexedDB direct ? Parce que pi-web-ui est aussi utilisé dans des contextes d'extension et distants ; une fois StorageBackend abstrait, l'hôte peut passer un backend implémenté via une API distante sans toucher au code des stores. La classe Store ne sait pas si le fond est IndexedDB ou HTTP : elle récupère juste un objet conforme à l'interface via getBackend().
Fichiers clés
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage, les quatre champs store plus backend.packages/web-ui/src/storage/app-storage.ts:48-60— singleton globalgetAppStorage/setAppStorage.packages/web-ui/src/storage/store.ts:7-33— classe abstraiteStore,getConfig/setBackend/getBackend.packages/web-ui/src/storage/types.ts:29-88— interfaceStorageBackendetStorageTransaction.packages/web-ui/src/storage/types.ts:94-168— typesSessionMetadataetSessionData, qui déterminent les champs liste vs détail.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDBetonupgradeneeded, création des stores et index selon la config.packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex, parcours par curseur dans l'ordre de l'index.packages/web-ui/src/storage/stores/sessions-store.ts:30-55— transaction inter-store poursave/delete,getAllMetadatavia l'indexlastModified.packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore, pas de keyPath, utilise des clés out-of-line.packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore, stocke la clé par nom de provider.packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore, providers LLM définis par l'utilisateur.
La structure d'AppStorage est simple, elle ne fait qu'agréger les 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 utilise une transaction inter-store pour garantir la cohérence entre métadonnées et données complètes :
// 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 crée les stores et index selon la config de chaque store sur 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,
});
}
}
}
}
};Flux de données
À l'écriture d'une session, AppStorage.sessions.save écrit de façon atomique dans deux stores ; pour la lecture de la liste, on ne touche qu'au store de métadonnées :
Limites et cas d'échec
- Non initialisé : si
getAppStorage()est appelé avantsetAppStorage, on lèveAppStorage not initialized, voirpackages/web-ui/src/storage/app-storage.ts:48-53. - Store sans backend :
Store.getBackend()lèveBackend not set on <ClassName>pour signaler une erreur dans le flux d'assemblage de l'hôte, voirpackages/web-ui/src/storage/store.ts:27-32. - Détection de quota indisponible : si
navigator.storage?.estimaten'existe pas, on retourne{usage:0, quota:0, percent:0}sans erreur, voirpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185. - Demande de persistance refusée : si
navigator.storage.persistretourne false,requestPersistenceretourne false ; les données peuvent être nettoyées par le navigateur, l'hôte doit prévenir l'utilisateur. - Out-of-line key vs keyPath : au
set, si le store a un keyPath, on faitstore.put(value); sinonstore.put(value, key).SettingsStoreetProviderKeysStoren'ont pas de keyPath et utilisent des clés out-of-line, voirpackages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73. - Montée de version : quand
IndexedDBConfig.versionaugmente,onupgradeneededse déclenche, mais le code ne crée que les stores manquants — pas de migration de champs. L'hôte doit étendre lui-même le schéma.
Pour résumer
AppStorage est une façade ; en dessous, l'interface StorageBackend (par défaut IndexedDBStorageBackend), et chaque sous-classe de Store déclare son propre schéma IndexedDB. Les sessions utilisent les deux stores sessions + sessions-metadata pour séparer liste et détail. AgentInterface lit la clé provider et les réglages proxy via getAppStorage() ; ces deux points de consommation se voient dans AgentInterface, hôte de session et Proxy CORS et createStreamFn.