Skip to content

AppStorage と IndexedDB

源码版本v0.73.1

pi-web-ui はすべての永続化要件を一つの AppStorage ファサード (facade) に押し込む。provider API key、ユーザー設定、会話履歴、カスタム provider。裏側は IndexedDB で、StorageBackend インターフェースで抽象化されているので、リモートストレージにも差し替え可能だ。各 store は自分の IndexedDB スキーマ(config と indices)を宣言し、AppStorage は構築時にこれらの store と backend を繋ぐ。グローバルアクセス用の単例 getAppStorage() を提供する。

責務

  1. ファサード:AppStoragesettingsproviderKeyssessionscustomProviders の 4 つの store と backend を晒す。packages/web-ui/src/storage/app-storage.ts:11-39 参照。
  2. グローバル単例:getAppStorage()/setAppStorage() はモジュルレベルの単例。未初期化なら例外を投げる。packages/web-ui/src/storage/app-storage.ts:42-60 参照。
  3. Store 基底クラス:Store 抽象クラスはサブクラスに getConfig() の実装を求め、IndexedDB スキーマを返させる下位層へのアクセスは setBackend/getBackend 経由。packages/web-ui/src/storage/store.ts:7-33 参照。
  4. IndexedDB backend:IndexedDBStorageBackendStorageBackend インターフェースを実装し、onupgradeneeded 時に各 store の config に従って object store と index を作る。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46 参照。
  5. 会話ストレージ:SessionsStore は 2 つの object store を使う。sessions(全量メッセージ)と sessions-metadata(軽量メタデータ)。save 時はストアをまたぐトランザクションで原子的に書く。packages/web-ui/src/storage/stores/sessions-store.ts:30-35 参照。
  6. クォータ管理:getQuotaInfonavigator.storage.estimate を呼び、requestPersistencenavigator.storage.persist を呼ぶ。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192 参照。

設計動機

なぜ localStorage を使わないのか。理由は 3 つ。まず API key や会話履歴のようなデータは localStorage の 5MB 上限を超えかねない。次に IndexedDB はインデックスをサポートし、sessions-metadatalastModified インデックスにより getAllMetadata は時刻降順で直接返せる。全量走査が要らない。最後に navigator.storage.estimatepersist でアプリはクォータを把握し永続化を要求でき、ブラウザに圧力下でデータを消されるのを防げる。

なぜ SessionsStoresessionssessions-metadata を分けるのか。会話リストページはタイトル、時刻、メッセージ数、preview のような軽量フィールドだけで、各メッセージの全量 content を引っ張る必要がない。metadata を別に持ち、リストページは getAllFromIndex("sessions-metadata", "lastModified", "desc") で一発で取る。ユーザーが特定の会話を開いたときだけ get("sessions", id) で全量を読む。典型的なリスト/詳細の分離だ。

なぜ backend を IndexedDB 直結ではなくインターフェースにするのか。pi-web-ui は拡張やリモートのシナリオでも使われるからだ。StorageBackend を抽象化しておけば、host はリモート API を実装した backend を差し込めて、store コードは一切変わらない。Store 基底クラスは下位が IndexedDB なのか HTTP なのかを知らず、getBackend() でインターフェースに合致するオブジェクトを受け取るだけだ。

主要ファイル

AppStorage の構造はシンプルで、各 store を集約するだけ:

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 はストアまたぎトランザクションで metadata と全量データの整合を保証する:

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.getDBonupgradeneeded 時に各 store config に従って store と index を作る:

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

データフロー

会話を書くとき、AppStorage.sessions.save は 2 つの store にまたがって原子的に書く。リストの読み込みは metadata store だけを通る:

境界と失敗

  • 未初期化:setAppStorage 前に getAppStorage() を呼ぶと AppStorage not initialized を投げる。packages/web-ui/src/storage/app-storage.ts:48-53 参照。
  • store に backend 未設定:Store.getBackend()Backend not set on <ClassName> を投げ、host の組み立てフローに不備があることを示す。packages/web-ui/src/storage/store.ts:27-32 参照。
  • クォータ検出不可:navigator.storage?.estimate が無い場合は {usage:0, quota:0, percent:0} を返し、エラーにはしない。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-185 参照。
  • persist 要求の拒否:navigator.storage.persist が false を返した場合、requestPersistence は false を返す。データはブラウザに消される可能性があり、host はユーザーに警告すべきだ。
  • out-of-line key と keyPath の区別:set 時、store に keyPath があれば store.put(value)、無ければ store.put(value, key)SettingsStoreProviderKeysStore はどちらも keyPath 無しで out-of-line key を使う。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:68-73 参照。
  • バージョンアップグレード:IndexedDBConfig.version が上がると onupgradeneeded が発火するが、コードは存在しない store を作るだけで、フィールドのマイグレーションは扱わない。host がスキーマを変えるときは自前で拡張する必要がある。

まとめ

AppStorage はファサード (facade) で、その下に StorageBackend インターフェース(デフォルトは IndexedDBStorageBackend)があり、各 Store サブクラスが自身の IndexedDB スキーマを宣言する。会話は sessionssessions-metadata の 2 store でリスト/詳細を分離する。AgentInterfacegetAppStorage() 経由で provider key と proxy 設定を読む。この 2 つの消費点は AgentInterface セッションホストCORS プロキシと createStreamFn を参照。