AppStorage と IndexedDB
pi-web-ui はすべての永続化要件を一つの AppStorage ファサード (facade) に押し込む。provider API key、ユーザー設定、会話履歴、カスタム provider。裏側は IndexedDB で、StorageBackend インターフェースで抽象化されているので、リモートストレージにも差し替え可能だ。各 store は自分の IndexedDB スキーマ(config と indices)を宣言し、AppStorage は構築時にこれらの store と backend を繋ぐ。グローバルアクセス用の単例 getAppStorage() を提供する。
責務
- ファサード:
AppStorageはsettings、providerKeys、sessions、customProvidersの 4 つの store とbackendを晒す。packages/web-ui/src/storage/app-storage.ts:11-39参照。 - グローバル単例:
getAppStorage()/setAppStorage()はモジュルレベルの単例。未初期化なら例外を投げる。packages/web-ui/src/storage/app-storage.ts:42-60参照。 - Store 基底クラス:
Store抽象クラスはサブクラスにgetConfig()の実装を求め、IndexedDB スキーマを返させる下位層へのアクセスはsetBackend/getBackend経由。packages/web-ui/src/storage/store.ts:7-33参照。 - IndexedDB backend:
IndexedDBStorageBackendはStorageBackendインターフェースを実装し、onupgradeneeded時に各 store の config に従って object store と index を作る。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46参照。 - 会話ストレージ:
SessionsStoreは 2 つの object store を使う。sessions(全量メッセージ)とsessions-metadata(軽量メタデータ)。save時はストアをまたぐトランザクションで原子的に書く。packages/web-ui/src/storage/stores/sessions-store.ts:30-35参照。 - クォータ管理:
getQuotaInfoはnavigator.storage.estimateを呼び、requestPersistenceはnavigator.storage.persistを呼ぶ。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:175-192参照。
設計動機
なぜ localStorage を使わないのか。理由は 3 つ。まず API key や会話履歴のようなデータは localStorage の 5MB 上限を超えかねない。次に IndexedDB はインデックスをサポートし、sessions-metadata の lastModified インデックスにより getAllMetadata は時刻降順で直接返せる。全量走査が要らない。最後に navigator.storage.estimate と persist でアプリはクォータを把握し永続化を要求でき、ブラウザに圧力下でデータを消されるのを防げる。
なぜ SessionsStore は sessions と sessions-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() でインターフェースに合致するオブジェクトを受け取るだけだ。
主要ファイル
packages/web-ui/src/storage/app-storage.ts:11-39—class AppStorage、4 つの store フィールドと backend。packages/web-ui/src/storage/app-storage.ts:48-60—getAppStorage/setAppStorageグローバル単例。packages/web-ui/src/storage/store.ts:7-33—Store抽象基底、getConfig/setBackend/getBackend。packages/web-ui/src/storage/types.ts:29-88—StorageBackendインターフェースとStorageTransaction。packages/web-ui/src/storage/types.ts:94-168—SessionMetadataとSessionData型、リスト vs 詳細のフィールドを決める。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:7-46—getDBとonupgradeneeded、config に従って store と index を作る。packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts:99-125—getAllFromIndex、cursor でインデックス順に走査。packages/web-ui/src/storage/stores/sessions-store.ts:30-55—save/deleteのストアまたぎトランザクション、getAllMetadataはlastModifiedインデックスを使用。packages/web-ui/src/storage/stores/settings-store.ts:7-34—SettingsStore、keyPath 無しで out-of-line key を使用。packages/web-ui/src/storage/stores/provider-keys-store.ts:7-33—ProviderKeysStore、provider 名で key を保存。packages/web-ui/src/storage/stores/custom-providers-store.ts:28-62—CustomProvidersStore、ユーザー定義 LLM provider。
AppStorage の構造はシンプルで、各 store を集約するだけ:
// 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 と全量データの整合を保証する:
// 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 は onupgradeneeded 時に各 store config に従って store と index を作る:
// 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)。SettingsStoreとProviderKeysStoreはどちらも 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 スキーマを宣言する。会話は sessions と sessions-metadata の 2 store でリスト/詳細を分離する。AgentInterface は getAppStorage() 経由で provider key と proxy 設定を読む。この 2 つの消費点は AgentInterface セッションホスト と CORS プロキシと createStreamFn を参照。