Skip to content

AppStorage et IndexedDB

源码版本v0.73.1

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

  1. Façade : AppStorage expose quatre stores — settings, providerKeys, sessions, customProviders — plus backend, voir packages/web-ui/src/storage/app-storage.ts:11-39.
  2. Singleton global : getAppStorage()/setAppStorage() sont des singletons au niveau module ; une erreur est levée si non initialisé, voir packages/web-ui/src/storage/app-storage.ts:42-60.
  3. Classe de base Store : la classe abstraite Store demande aux sous-classes d'implémenter getConfig() pour renvoyer le schéma IndexedDB ; on accède au backend via setBackend/getBackend, voir packages/web-ui/src/storage/store.ts:7-33.
  4. Backend IndexedDB : IndexedDBStorageBackend implémente l'interface StorageBackend ; sur onupgradeneeded, il crée les object stores et index selon la config de chaque store, voir packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46.
  5. Stockage des sessions : SessionsStore utilise deux object stores — sessions (messages complets) et sessions-metadata (métadonnées légères) ; save écrit de façon atomique dans une transaction inter-store, voir packages/web-ui/src/storage/stores/sessions-store.ts:30-35.
  6. Gestion du quota : getQuotaInfo appelle navigator.storage.estimate, requestPersistence appelle navigator.storage.persist, voir packages/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

La structure d'AppStorage est simple, elle ne fait qu'agréger les 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 utilise une transaction inter-store pour garantir la cohérence entre métadonnées et données complètes :

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 crée les stores et index selon la config de chaque store sur 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,
                    });
                }
            }
        }
    }
};

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é avant setAppStorage, on lève AppStorage not initialized, voir packages/web-ui/src/storage/app-storage.ts:48-53.
  • Store sans backend : Store.getBackend() lève Backend not set on <ClassName> pour signaler une erreur dans le flux d'assemblage de l'hôte, voir packages/web-ui/src/storage/store.ts:27-32.
  • Détection de quota indisponible : si navigator.storage?.estimate n'existe pas, on retourne {usage:0, quota:0, percent:0} sans erreur, voir packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185.
  • Demande de persistance refusée : si navigator.storage.persist retourne false, requestPersistence retourne 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 fait store.put(value) ; sinon store.put(value, key). SettingsStore et ProviderKeysStore n'ont pas de keyPath et utilisent des clés out-of-line, voir packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73.
  • Montée de version : quand IndexedDBConfig.version augmente, onupgradeneeded se 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.