diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 982821c4..5521cdd7 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -9,10 +9,10 @@ import { type AppMessage, AppStorage, ChatPanel, + IndexedDBStorageBackend, // PersistentStorageDialog, // TODO: Fix - currently broken ProviderTransport, ProxyTab, - SessionIndexedDBBackend, SessionListDialog, setAppStorage, SettingsDialog, @@ -25,9 +25,17 @@ import { createSystemNotification, customMessageTransformer, registerCustomMessa // Register custom message renderers registerCustomMessageRenderers(); -const storage = new AppStorage({ - sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"), +const backend = new IndexedDBStorageBackend({ + dbName: "pi-web-ui-example", + version: 1, + stores: [ + { name: "sessions-metadata", keyPath: "id", indices: [{ name: "lastModified", keyPath: "lastModified" }] }, + { name: "sessions-data", keyPath: "id" }, + { name: "settings" }, + { name: "provider-keys" }, + ], }); +const storage = new AppStorage(backend); setAppStorage(storage); let currentSessionId: string | undefined; @@ -74,7 +82,43 @@ const saveSession = async () => { if (!shouldSaveSession(state.messages)) return; try { - await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle); + // Create session data + const sessionData = { + id: currentSessionId, + title: currentTitle, + model: state.model!, + thinkingLevel: state.thinkingLevel, + messages: state.messages, + createdAt: new Date().toISOString(), + lastModified: new Date().toISOString(), + }; + + // Create session metadata + const metadata = { + id: currentSessionId, + title: currentTitle, + createdAt: sessionData.createdAt, + lastModified: sessionData.lastModified, + messageCount: state.messages.length, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + modelId: state.model?.id || null, + thinkingLevel: state.thinkingLevel, + preview: generateTitle(state.messages), + }; + + await storage.sessions.saveSession(sessionData, metadata); } catch (err) { console.error("Failed to save session:", err); } @@ -142,7 +186,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons const loadSession = async (sessionId: string): Promise => { if (!storage.sessions) return false; - const sessionData = await storage.sessions.loadSession(sessionId); + const sessionData = await storage.sessions.getSession(sessionId); if (!sessionData) { console.error("Session not found:", sessionId); return false; diff --git a/packages/web-ui/src/agent/transports/ProviderTransport.ts b/packages/web-ui/src/agent/transports/ProviderTransport.ts index 8678364a..244187cb 100644 --- a/packages/web-ui/src/agent/transports/ProviderTransport.ts +++ b/packages/web-ui/src/agent/transports/ProviderTransport.ts @@ -9,14 +9,14 @@ import type { AgentRunConfig, AgentTransport } from "./types.js"; export class ProviderTransport implements AgentTransport { async *run(messages: Message[], userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) { // Get API key from storage - const apiKey = await getAppStorage().providerKeys.getKey(cfg.model.provider); + const apiKey = await getAppStorage().getProviderKey(cfg.model.provider); if (!apiKey) { throw new Error("no-api-key"); } // Check if CORS proxy is enabled - const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); - const proxyUrl = await getAppStorage().settings.get("proxy.url"); + const proxyEnabled = await getAppStorage().getSetting("proxy.enabled"); + const proxyUrl = await getAppStorage().getSetting("proxy.url"); // Clone model and modify baseUrl if proxy is enabled let model = cfg.model; diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index 3c2acf97..3040f085 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -173,7 +173,7 @@ export class AgentInterface extends LitElement { // Check if API key exists for the provider (only needed in direct mode) const provider = session.state.model.provider; - const apiKey = await getAppStorage().providerKeys.getKey(provider); + const apiKey = await getAppStorage().getProviderKey(provider); // If no API key, prompt for it if (!apiKey) { diff --git a/packages/web-ui/src/components/ProviderKeyInput.ts b/packages/web-ui/src/components/ProviderKeyInput.ts index d4720e23..ede6e159 100644 --- a/packages/web-ui/src/components/ProviderKeyInput.ts +++ b/packages/web-ui/src/components/ProviderKeyInput.ts @@ -35,7 +35,7 @@ export class ProviderKeyInput extends LitElement { private async checkKeyStatus() { try { - const key = await getAppStorage().providerKeys.getKey(this.provider); + const key = await getAppStorage().getProviderKey(this.provider); this.hasKey = !!key; } catch (error) { console.error("Failed to check key status:", error); @@ -51,8 +51,8 @@ export class ProviderKeyInput extends LitElement { if (!model) return false; // Check if CORS proxy is enabled and apply it - const proxyEnabled = await getAppStorage().settings.get("proxy.enabled"); - const proxyUrl = await getAppStorage().settings.get("proxy.url"); + const proxyEnabled = await getAppStorage().getSetting("proxy.enabled"); + const proxyUrl = await getAppStorage().getSetting("proxy.url"); if (proxyEnabled && proxyUrl && model.baseUrl) { model = { @@ -89,7 +89,7 @@ export class ProviderKeyInput extends LitElement { if (success) { try { - await getAppStorage().providerKeys.setKey(this.provider, this.keyInput); + await getAppStorage().setProviderKey(this.provider, this.keyInput); this.hasKey = true; this.keyInput = ""; this.requestUpdate(); @@ -112,7 +112,7 @@ export class ProviderKeyInput extends LitElement { private async removeKey() { try { - await getAppStorage().providerKeys.removeKey(this.provider); + await getAppStorage().deleteProviderKey(this.provider); this.hasKey = false; this.keyInput = ""; this.requestUpdate(); diff --git a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts index 6f450e4c..0a54db9e 100644 --- a/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts +++ b/packages/web-ui/src/dialogs/ApiKeyPromptDialog.ts @@ -29,7 +29,7 @@ export class ApiKeyPromptDialog extends DialogBase { // Poll for key existence - when key is added, resolve and close const checkInterval = setInterval(async () => { - const hasKey = await getAppStorage().providerKeys.hasKey(this.provider); + const hasKey = !!(await getAppStorage().getProviderKey(this.provider)); if (hasKey) { clearInterval(checkInterval); if (this.resolvePromise) { diff --git a/packages/web-ui/src/dialogs/SessionListDialog.ts b/packages/web-ui/src/dialogs/SessionListDialog.ts index c17f834d..d151cb79 100644 --- a/packages/web-ui/src/dialogs/SessionListDialog.ts +++ b/packages/web-ui/src/dialogs/SessionListDialog.ts @@ -30,13 +30,7 @@ export class SessionListDialog extends DialogBase { this.loading = true; try { const storage = getAppStorage(); - if (!storage.sessions) { - console.error("Session storage not available"); - this.sessions = []; - return; - } - - this.sessions = await storage.sessions.listSessions(); + this.sessions = await storage.sessions.getAllMetadata(); } catch (err) { console.error("Failed to load sessions:", err); this.sessions = []; diff --git a/packages/web-ui/src/dialogs/SettingsDialog.ts b/packages/web-ui/src/dialogs/SettingsDialog.ts index 20717367..5c29aa03 100644 --- a/packages/web-ui/src/dialogs/SettingsDialog.ts +++ b/packages/web-ui/src/dialogs/SettingsDialog.ts @@ -56,8 +56,8 @@ export class ProxyTab extends SettingsTab { // Load proxy settings when tab is connected try { const storage = getAppStorage(); - const enabled = await storage.settings.get("proxy.enabled"); - const url = await storage.settings.get("proxy.url"); + const enabled = await storage.getSetting("proxy.enabled"); + const url = await storage.getSetting("proxy.url"); if (enabled !== null) this.proxyEnabled = enabled; if (url !== null) this.proxyUrl = url; @@ -69,8 +69,8 @@ export class ProxyTab extends SettingsTab { private async saveProxySettings() { try { const storage = getAppStorage(); - await storage.settings.set("proxy.enabled", this.proxyEnabled); - await storage.settings.set("proxy.url", this.proxyUrl); + await storage.setSetting("proxy.enabled", this.proxyEnabled); + await storage.setSetting("proxy.url", this.proxyUrl); } catch (error) { console.error("Failed to save proxy settings:", error); } diff --git a/packages/web-ui/src/index.ts b/packages/web-ui/src/index.ts index 41f93386..3fa97cb3 100644 --- a/packages/web-ui/src/index.ts +++ b/packages/web-ui/src/index.ts @@ -43,20 +43,17 @@ export { PersistentStorageDialog } from "./dialogs/PersistentStorageDialog.js"; export { SessionListDialog } from "./dialogs/SessionListDialog.js"; export { ApiKeysTab, ProxyTab, SettingsDialog, SettingsTab } from "./dialogs/SettingsDialog.js"; // Storage -export { AppStorage, getAppStorage, initAppStorage, setAppStorage } from "./storage/app-storage.js"; -export { IndexedDBBackend } from "./storage/backends/indexeddb-backend.js"; -export { LocalStorageBackend } from "./storage/backends/local-storage-backend.js"; -export { SessionIndexedDBBackend } from "./storage/backends/session-indexeddb-backend.js"; -export { WebExtensionStorageBackend } from "./storage/backends/web-extension-storage-backend.js"; -export { ProviderKeysRepository } from "./storage/repositories/provider-keys-repository.js"; -export { SessionRepository } from "./storage/repositories/session-repository.js"; -export { SettingsRepository } from "./storage/repositories/settings-repository.js"; +export { AppStorage, getAppStorage, setAppStorage } from "./storage/app-storage.js"; +export { IndexedDBStorageBackend } from "./storage/backends/indexeddb-storage-backend.js"; +export { SessionsRepository } from "./storage/sessions-repository.js"; export type { - AppStorageConfig, + IndexConfig, + IndexedDBConfig, SessionData, SessionMetadata, - SessionStorageBackend, StorageBackend, + StorageTransaction, + StoreConfig, } from "./storage/types.js"; // Artifacts export { ArtifactElement } from "./tools/artifacts/ArtifactElement.js"; diff --git a/packages/web-ui/src/storage/app-storage.ts b/packages/web-ui/src/storage/app-storage.ts index 69d20ed6..5ea93e94 100644 --- a/packages/web-ui/src/storage/app-storage.ts +++ b/packages/web-ui/src/storage/app-storage.ts @@ -1,30 +1,60 @@ -import { LocalStorageBackend } from "./backends/local-storage-backend.js"; -import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js"; -import { SessionRepository } from "./repositories/session-repository.js"; -import { SettingsRepository } from "./repositories/settings-repository.js"; -import type { AppStorageConfig } from "./types.js"; +import { SessionsRepository } from "./sessions-repository.js"; +import type { StorageBackend } from "./types.js"; /** - * High-level storage API aggregating all repositories. - * Apps configure backends and use repositories through this interface. + * High-level storage API providing access to all storage operations. + * Subclasses can extend this to add domain-specific repositories. */ export class AppStorage { - readonly settings: SettingsRepository; - readonly providerKeys: ProviderKeysRepository; - readonly sessions?: SessionRepository; + readonly backend: StorageBackend; + readonly sessions: SessionsRepository; - constructor(config: AppStorageConfig = {}) { - // Use LocalStorage with prefixes as defaults - const settingsBackend = config.settings ?? new LocalStorageBackend("settings"); - const providerKeysBackend = config.providerKeys ?? new LocalStorageBackend("providerKeys"); + constructor(backend: StorageBackend) { + this.backend = backend; + this.sessions = new SessionsRepository(backend); + } - this.settings = new SettingsRepository(settingsBackend); - this.providerKeys = new ProviderKeysRepository(providerKeysBackend); + // Settings access (delegates to "settings" store) + async getSetting(key: string): Promise { + return this.backend.get("settings", key); + } - // Session storage is optional - if (config.sessions) { - this.sessions = new SessionRepository(config.sessions); - } + async setSetting(key: string, value: T): Promise { + await this.backend.set("settings", key, value); + } + + async deleteSetting(key: string): Promise { + await this.backend.delete("settings", key); + } + + async listSettings(): Promise { + return this.backend.keys("settings"); + } + + // Provider keys access (delegates to "provider-keys" store) + async getProviderKey(provider: string): Promise { + return this.backend.get("provider-keys", provider); + } + + async setProviderKey(provider: string, key: string): Promise { + await this.backend.set("provider-keys", provider, key); + } + + async deleteProviderKey(provider: string): Promise { + await this.backend.delete("provider-keys", provider); + } + + async listProviderKeys(): Promise { + return this.backend.keys("provider-keys"); + } + + // Quota management + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + return this.backend.getQuotaInfo(); + } + + async requestPersistence(): Promise { + return this.backend.requestPersistence(); } } @@ -48,13 +78,3 @@ export function getAppStorage(): AppStorage { export function setAppStorage(storage: AppStorage): void { globalAppStorage = storage; } - -/** - * Initialize AppStorage with default configuration if not already set. - */ -export function initAppStorage(config: AppStorageConfig = {}): AppStorage { - if (!globalAppStorage) { - globalAppStorage = new AppStorage(config); - } - return globalAppStorage; -} diff --git a/packages/web-ui/src/storage/backends/indexeddb-backend.ts b/packages/web-ui/src/storage/backends/indexeddb-backend.ts deleted file mode 100644 index b6ecaf03..00000000 --- a/packages/web-ui/src/storage/backends/indexeddb-backend.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { StorageBackend } from "../types.js"; - -/** - * Storage backend using IndexedDB. - * Good for: Large data, binary blobs, complex queries. - * Limits: ~50MB-unlimited (browser dependent), async API, more complex. - */ -export class IndexedDBBackend implements StorageBackend { - private dbPromise: Promise | null = null; - - constructor( - private dbName: string, - private storeName: string = "keyvalue", - ) {} - - private async getDB(): Promise { - if (this.dbPromise) { - return this.dbPromise; - } - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - } - }; - }); - - return this.dbPromise; - } - - async get(key: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(this.storeName, "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.get(key); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const value = request.result; - resolve(value !== undefined ? (value as T) : null); - }; - }); - } - - async set(key: string, value: T): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(this.storeName, "readwrite"); - const store = transaction.objectStore(this.storeName); - const request = store.put(value, key); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } - - async delete(key: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(this.storeName, "readwrite"); - const store = transaction.objectStore(this.storeName); - const request = store.delete(key); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } - - async keys(): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(this.storeName, "readonly"); - const store = transaction.objectStore(this.storeName); - const request = store.getAllKeys(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - resolve(request.result.map((key) => String(key))); - }; - }); - } - - async clear(): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(this.storeName, "readwrite"); - const store = transaction.objectStore(this.storeName); - const request = store.clear(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } - - async has(key: string): Promise { - const value = await this.get(key); - return value !== null; - } -} diff --git a/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts b/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts new file mode 100644 index 00000000..86fc144f --- /dev/null +++ b/packages/web-ui/src/storage/backends/indexeddb-storage-backend.ts @@ -0,0 +1,153 @@ +import type { IndexedDBConfig, StorageBackend, StorageTransaction } from "../types.js"; + +/** + * IndexedDB implementation of StorageBackend. + * Provides multi-store key-value storage with transactions and quota management. + */ +export class IndexedDBStorageBackend implements StorageBackend { + private dbPromise: Promise | null = null; + + constructor(private config: IndexedDBConfig) {} + + private async getDB(): Promise { + if (!this.dbPromise) { + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.config.dbName, this.config.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = request.result; + + // Create object stores from config + 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, + }); + + // Create indices + if (storeConfig.indices) { + for (const indexConfig of storeConfig.indices) { + store.createIndex(indexConfig.name, indexConfig.keyPath, { + unique: indexConfig.unique, + }); + } + } + } + } + }; + }); + } + + return this.dbPromise; + } + + private promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async get(storeName: string, key: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const result = await this.promisifyRequest(store.get(key)); + return result ?? null; + } + + async set(storeName: string, key: string, value: T): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + await this.promisifyRequest(store.put(value, key)); + } + + async delete(storeName: string, key: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + await this.promisifyRequest(store.delete(key)); + } + + async keys(storeName: string, prefix?: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + + if (prefix) { + // Use IDBKeyRange for efficient prefix filtering + const range = IDBKeyRange.bound(prefix, prefix + "\uffff", false, false); + const keys = await this.promisifyRequest(store.getAllKeys(range)); + return keys.map((k) => String(k)); + } else { + const keys = await this.promisifyRequest(store.getAllKeys()); + return keys.map((k) => String(k)); + } + } + + async clear(storeName: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + await this.promisifyRequest(store.clear()); + } + + async has(storeName: string, key: string): Promise { + const db = await this.getDB(); + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const result = await this.promisifyRequest(store.getKey(key)); + return result !== undefined; + } + + async transaction( + storeNames: string[], + mode: "readonly" | "readwrite", + operation: (tx: StorageTransaction) => Promise, + ): Promise { + const db = await this.getDB(); + const idbTx = db.transaction(storeNames, mode); + + const storageTx: StorageTransaction = { + get: async (storeName: string, key: string) => { + const store = idbTx.objectStore(storeName); + const result = await this.promisifyRequest(store.get(key)); + return (result ?? null) as T | null; + }, + set: async (storeName: string, key: string, value: T) => { + const store = idbTx.objectStore(storeName); + await this.promisifyRequest(store.put(value, key)); + }, + delete: async (storeName: string, key: string) => { + const store = idbTx.objectStore(storeName); + await this.promisifyRequest(store.delete(key)); + }, + }; + + return operation(storageTx); + } + + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + if (navigator.storage && navigator.storage.estimate) { + const estimate = await navigator.storage.estimate(); + return { + usage: estimate.usage || 0, + quota: estimate.quota || 0, + percent: estimate.quota ? ((estimate.usage || 0) / estimate.quota) * 100 : 0, + }; + } + return { usage: 0, quota: 0, percent: 0 }; + } + + async requestPersistence(): Promise { + if (navigator.storage && navigator.storage.persist) { + return await navigator.storage.persist(); + } + return false; + } +} diff --git a/packages/web-ui/src/storage/backends/local-storage-backend.ts b/packages/web-ui/src/storage/backends/local-storage-backend.ts deleted file mode 100644 index 210ed0bf..00000000 --- a/packages/web-ui/src/storage/backends/local-storage-backend.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { StorageBackend } from "../types.js"; - -/** - * Storage backend using browser localStorage. - * Good for: Simple settings, small data. - * Limits: ~5MB, synchronous API (wrapped in promises), string-only (JSON serialization). - */ -export class LocalStorageBackend implements StorageBackend { - constructor(private prefix: string = "") {} - - private getKey(key: string): string { - return this.prefix ? `${this.prefix}:${key}` : key; - } - - async get(key: string): Promise { - const fullKey = this.getKey(key); - const value = localStorage.getItem(fullKey); - if (value === null) return null; - - try { - return JSON.parse(value) as T; - } catch { - // If JSON parse fails, return as string - return value as T; - } - } - - async set(key: string, value: T): Promise { - const fullKey = this.getKey(key); - const serialized = JSON.stringify(value); - localStorage.setItem(fullKey, serialized); - } - - async delete(key: string): Promise { - const fullKey = this.getKey(key); - localStorage.removeItem(fullKey); - } - - async keys(): Promise { - const allKeys: string[] = []; - const prefixWithColon = this.prefix ? `${this.prefix}:` : ""; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key) { - if (this.prefix) { - if (key.startsWith(prefixWithColon)) { - allKeys.push(key.substring(prefixWithColon.length)); - } - } else { - allKeys.push(key); - } - } - } - - return allKeys; - } - - async clear(): Promise { - if (this.prefix) { - const keysToRemove = await this.keys(); - for (const key of keysToRemove) { - await this.delete(key); - } - } else { - localStorage.clear(); - } - } - - async has(key: string): Promise { - const fullKey = this.getKey(key); - return localStorage.getItem(fullKey) !== null; - } -} diff --git a/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts b/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts deleted file mode 100644 index 1bf029e8..00000000 --- a/packages/web-ui/src/storage/backends/session-indexeddb-backend.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js"; - -/** - * IndexedDB implementation of session storage. - * Uses two object stores: - * - "metadata": Fast access for listing/searching - * - "data": Full session data loaded on demand - */ -export class SessionIndexedDBBackend implements SessionStorageBackend { - private dbPromise: Promise | null = null; - private readonly DB_NAME: string; - private readonly DB_VERSION = 1; - - constructor(dbName = "pi-sessions") { - this.DB_NAME = dbName; - } - - private async getDB(): Promise { - if (this.dbPromise) { - return this.dbPromise; - } - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - - // Object store for metadata (lightweight, frequently accessed) - if (!db.objectStoreNames.contains("metadata")) { - const metaStore = db.createObjectStore("metadata", { keyPath: "id" }); - // Index for sorting by last modified - metaStore.createIndex("lastModified", "lastModified", { unique: false }); - } - - // Object store for full session data (heavy, rarely accessed) - if (!db.objectStoreNames.contains("data")) { - db.createObjectStore("data", { keyPath: "id" }); - } - }; - }); - - return this.dbPromise; - } - - async saveSession(data: SessionData, metadata: SessionMetadata): Promise { - const db = await this.getDB(); - - // Use transaction to ensure atomicity (both or neither) - return new Promise((resolve, reject) => { - const tx = db.transaction(["metadata", "data"], "readwrite"); - const metaStore = tx.objectStore("metadata"); - const dataStore = tx.objectStore("data"); - - // Save both in same transaction - const metaReq = metaStore.put(metadata); - const dataReq = dataStore.put(data); - - // Handle errors - metaReq.onerror = () => reject(metaReq.error); - dataReq.onerror = () => reject(dataReq.error); - - // Transaction complete = both saved - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - async getSession(id: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction("data", "readonly"); - const store = tx.objectStore("data"); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - resolve(request.result !== undefined ? (request.result as SessionData) : null); - }; - }); - } - - async getMetadata(id: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction("metadata", "readonly"); - const store = tx.objectStore("metadata"); - const request = store.get(id); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - resolve(request.result !== undefined ? (request.result as SessionMetadata) : null); - }; - }); - } - - async getAllMetadata(): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction("metadata", "readonly"); - const store = tx.objectStore("metadata"); - const request = store.getAll(); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - resolve(request.result as SessionMetadata[]); - }; - }); - } - - async deleteSession(id: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(["metadata", "data"], "readwrite"); - const metaStore = tx.objectStore("metadata"); - const dataStore = tx.objectStore("data"); - - // Delete both in transaction - const metaReq = metaStore.delete(id); - const dataReq = dataStore.delete(id); - - metaReq.onerror = () => reject(metaReq.error); - dataReq.onerror = () => reject(dataReq.error); - - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - async updateTitle(id: string, title: string): Promise { - const db = await this.getDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(["metadata", "data"], "readwrite"); - - // Update metadata - const metaStore = tx.objectStore("metadata"); - const metaReq = metaStore.get(id); - - metaReq.onsuccess = () => { - const metadata = metaReq.result as SessionMetadata; - if (!metadata) { - reject(new Error(`Session ${id} not found`)); - return; - } - metadata.title = title; - metadata.lastModified = new Date().toISOString(); - metaStore.put(metadata); - }; - - // Update data - const dataStore = tx.objectStore("data"); - const dataReq = dataStore.get(id); - - dataReq.onsuccess = () => { - const data = dataReq.result as SessionData; - if (!data) { - reject(new Error(`Session ${id} not found`)); - return; - } - data.title = title; - data.lastModified = new Date().toISOString(); - dataStore.put(data); - }; - - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - if (!navigator.storage || !navigator.storage.estimate) { - return { usage: 0, quota: 0, percent: 0 }; - } - - const estimate = await navigator.storage.estimate(); - const usage = estimate.usage || 0; - const quota = estimate.quota || 0; - const percent = quota > 0 ? (usage / quota) * 100 : 0; - - return { usage, quota, percent }; - } - - async requestPersistence(): Promise { - if (!navigator.storage || !navigator.storage.persist) { - return false; - } - - // Check if already persistent - const isPersisted = await navigator.storage.persisted(); - if (isPersisted) { - return true; - } - - // Request persistence - return await navigator.storage.persist(); - } -} diff --git a/packages/web-ui/src/storage/backends/web-extension-storage-backend.ts b/packages/web-ui/src/storage/backends/web-extension-storage-backend.ts deleted file mode 100644 index ba8c22cb..00000000 --- a/packages/web-ui/src/storage/backends/web-extension-storage-backend.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { StorageBackend } from "../types.js"; - -// Cross-browser extension API compatibility -// @ts-expect-error - browser global exists in Firefox, chrome in Chrome -const browserAPI = globalThis.browser || globalThis.chrome; - -/** - * Storage backend using browser.storage.local (Firefox) or chrome.storage.local (Chrome). - * Good for: Browser extensions, syncing across devices (with storage.sync). - * Limits: ~10MB for local, ~100KB for sync, async API. - */ -export class WebExtensionStorageBackend implements StorageBackend { - constructor(private prefix: string = "") {} - - private getKey(key: string): string { - return this.prefix ? `${this.prefix}:${key}` : key; - } - - async get(key: string): Promise { - if (!browserAPI?.storage?.local) { - throw new Error("browser/chrome.storage.local is not available"); - } - - const fullKey = this.getKey(key); - const result = await browserAPI.storage.local.get([fullKey]); - return result[fullKey] !== undefined ? (result[fullKey] as T) : null; - } - - async set(key: string, value: T): Promise { - if (!browserAPI?.storage?.local) { - throw new Error("browser/chrome.storage.local is not available"); - } - - const fullKey = this.getKey(key); - await browserAPI.storage.local.set({ [fullKey]: value }); - } - - async delete(key: string): Promise { - if (!browserAPI?.storage?.local) { - throw new Error("browser/chrome.storage.local is not available"); - } - - const fullKey = this.getKey(key); - await browserAPI.storage.local.remove(fullKey); - } - - async keys(): Promise { - if (!browserAPI?.storage?.local) { - throw new Error("browser/chrome.storage.local is not available"); - } - - const allData = await browserAPI.storage.local.get(null); - const allKeys = Object.keys(allData); - const prefixWithColon = this.prefix ? `${this.prefix}:` : ""; - - if (this.prefix) { - return allKeys - .filter((key) => key.startsWith(prefixWithColon)) - .map((key) => key.substring(prefixWithColon.length)); - } - - return allKeys; - } - - async clear(): Promise { - if (!browserAPI?.storage?.local) { - throw new Error("browser/chrome.storage.local is not available"); - } - - if (this.prefix) { - const keysToRemove = await this.keys(); - const fullKeys = keysToRemove.map((key) => this.getKey(key)); - await browserAPI.storage.local.remove(fullKeys); - } else { - await browserAPI.storage.local.clear(); - } - } - - async has(key: string): Promise { - const value = await this.get(key); - return value !== null; - } -} diff --git a/packages/web-ui/src/storage/repositories/provider-keys-repository.ts b/packages/web-ui/src/storage/repositories/provider-keys-repository.ts deleted file mode 100644 index 3852c0e2..00000000 --- a/packages/web-ui/src/storage/repositories/provider-keys-repository.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { StorageBackend } from "../types.js"; - -/** - * Repository for managing provider API keys. - * Provides domain-specific methods for key management. - */ -export class ProviderKeysRepository { - constructor(private backend: StorageBackend) {} - - /** - * Get the API key for a provider. - */ - async getKey(provider: string): Promise { - return this.backend.get(`key:${provider}`); - } - - /** - * Set the API key for a provider. - */ - async setKey(provider: string, key: string): Promise { - await this.backend.set(`key:${provider}`, key); - } - - /** - * Remove the API key for a provider. - */ - async removeKey(provider: string): Promise { - await this.backend.delete(`key:${provider}`); - } - - /** - * Get all providers that have keys stored. - */ - async getProviders(): Promise { - const allKeys = await this.backend.keys(); - return allKeys.filter((key) => key.startsWith("key:")).map((key) => key.substring(4)); - } - - /** - * Check if a provider has a key stored. - */ - async hasKey(provider: string): Promise { - return this.backend.has(`key:${provider}`); - } - - /** - * Clear all stored API keys. - */ - async clearAll(): Promise { - const providers = await this.getProviders(); - for (const provider of providers) { - await this.removeKey(provider); - } - } -} diff --git a/packages/web-ui/src/storage/repositories/session-repository.ts b/packages/web-ui/src/storage/repositories/session-repository.ts deleted file mode 100644 index 8923ed50..00000000 --- a/packages/web-ui/src/storage/repositories/session-repository.ts +++ /dev/null @@ -1,290 +0,0 @@ -import type { AgentState } from "../../agent/agent.js"; -import type { AppMessage } from "../../components/Messages.js"; -import type { SessionData, SessionMetadata, SessionStorageBackend } from "../types.js"; - -/** - * Repository for managing chat sessions. - * Handles business logic: title generation, metadata extraction, etc. - */ -export class SessionRepository { - constructor(public backend: SessionStorageBackend) {} - - /** - * Generate a title from the first user message. - * Takes first sentence or 50 chars, whichever is shorter. - */ - private generateTitle(messages: AppMessage[]): string { - const firstUserMsg = messages.find((m) => m.role === "user"); - if (!firstUserMsg) return "New Session"; - - // Extract text content - const content = firstUserMsg.content; - let text = ""; - - if (typeof content === "string") { - text = content; - } else { - const textBlocks = content.filter((c) => c.type === "text"); - text = textBlocks.map((c) => (c as any).text || "").join(" "); - } - - text = text.trim(); - if (!text) return "New Session"; - - // Find first sentence (up to 50 chars) - const sentenceEnd = text.search(/[.!?]/); - if (sentenceEnd > 0 && sentenceEnd <= 50) { - return text.substring(0, sentenceEnd + 1); - } - - // Otherwise take first 50 chars - return text.length <= 50 ? text : text.substring(0, 47) + "..."; - } - - /** - * Extract preview text from messages. - * Goes through all messages in sequence, extracts text content only - * (excludes tool calls, tool results, thinking blocks), until 2KB. - */ - private extractPreview(messages: AppMessage[]): string { - let preview = ""; - const MAX_SIZE = 2048; // 2KB total - - for (const msg of messages) { - // Skip tool result messages entirely - if (msg.role === "toolResult") { - continue; - } - - // UserMessage can have string or array content - if (msg.role === "user") { - const content = msg.content; - - if (typeof content === "string") { - // Simple string content - if (preview.length + content.length <= MAX_SIZE) { - preview += content + " "; - } else { - preview += content.substring(0, MAX_SIZE - preview.length); - return preview.trim(); - } - } else { - // Array of TextContent | ImageContent - const textBlocks = content.filter((c) => c.type === "text"); - for (const block of textBlocks) { - const text = (block as any).text || ""; - if (preview.length + text.length <= MAX_SIZE) { - preview += text + " "; - } else { - preview += text.substring(0, MAX_SIZE - preview.length); - return preview.trim(); - } - } - } - } - - // AssistantMessage has array of TextContent | ThinkingContent | ToolCall - if (msg.role === "assistant") { - // Filter to only TextContent (skip ThinkingContent and ToolCall) - const textBlocks = msg.content.filter((c) => c.type === "text"); - for (const block of textBlocks) { - const text = (block as any).text || ""; - if (preview.length + text.length <= MAX_SIZE) { - preview += text + " "; - } else { - preview += text.substring(0, MAX_SIZE - preview.length); - return preview.trim(); - } - } - } - - // Stop if we've hit the limit - if (preview.length >= MAX_SIZE) { - break; - } - } - - return preview.trim(); - } - - /** - * Calculate total usage across all messages. - */ - private calculateTotals(messages: AppMessage[]): { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - cost: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - total: number; - }; - } { - let input = 0; - let output = 0; - let cacheRead = 0; - let cacheWrite = 0; - const cost = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }; - - for (const msg of messages) { - if (msg.role === "assistant" && (msg as any).usage) { - const usage = (msg as any).usage; - input += usage.input || 0; - output += usage.output || 0; - cacheRead += usage.cacheRead || 0; - cacheWrite += usage.cacheWrite || 0; - if (usage.cost) { - cost.input += usage.cost.input || 0; - cost.output += usage.cost.output || 0; - cost.cacheRead += usage.cost.cacheRead || 0; - cost.cacheWrite += usage.cost.cacheWrite || 0; - cost.total += usage.cost.total || 0; - } - } - } - - return { input, output, cacheRead, cacheWrite, cost }; - } - - /** - * Extract metadata from session data. - */ - private extractMetadata(data: SessionData): SessionMetadata { - const usage = this.calculateTotals(data.messages); - const preview = this.extractPreview(data.messages); - - return { - id: data.id, - title: data.title, - createdAt: data.createdAt, - lastModified: data.lastModified, - messageCount: data.messages.length, - usage, - modelId: data.model?.id || null, - thinkingLevel: data.thinkingLevel, - preview, - }; - } - - /** - * Save session state. - * Extracts metadata and saves both atomically. - */ - async saveSession( - sessionId: string, - state: AgentState, - existingCreatedAt?: string, - existingTitle?: string, - ): Promise { - const now = new Date().toISOString(); - - const data: SessionData = { - id: sessionId, - title: existingTitle || this.generateTitle(state.messages), - model: state.model, - thinkingLevel: state.thinkingLevel, - messages: state.messages, - createdAt: existingCreatedAt || now, - lastModified: now, - }; - - const metadata = this.extractMetadata(data); - - await this.backend.saveSession(data, metadata); - } - - /** - * Load full session data by ID. - */ - async loadSession(id: string): Promise { - return this.backend.getSession(id); - } - - /** - * Get all session metadata, sorted by lastModified descending. - */ - async listSessions(): Promise { - const allMetadata = await this.backend.getAllMetadata(); - // Sort by lastModified descending (most recent first) - return allMetadata.sort((a, b) => { - return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime(); - }); - } - - /** - * Get the ID of the most recently modified session. - * Returns undefined if no sessions exist. - */ - async getLatestSessionId(): Promise { - const sessions = await this.listSessions(); - return sessions.length > 0 ? sessions[0].id : undefined; - } - - /** - * Search sessions by keyword. - * Searches in: title and preview (first 2KB of conversation text) - * Returns results sorted by relevance (uses simple substring search for now). - */ - async searchSessions(query: string): Promise { - if (!query.trim()) { - return this.listSessions(); - } - - const allMetadata = await this.backend.getAllMetadata(); - - // Simple substring search for now (can upgrade to Fuse.js later) - const lowerQuery = query.toLowerCase(); - const matches = allMetadata.filter((meta) => { - return meta.title.toLowerCase().includes(lowerQuery) || meta.preview.toLowerCase().includes(lowerQuery); - }); - - // Sort by lastModified descending - return matches.sort((a, b) => { - return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime(); - }); - } - - /** - * Get session metadata by ID. - */ - async getMetadata(id: string): Promise { - return this.backend.getMetadata(id); - } - - /** - * Delete a session. - */ - async deleteSession(id: string): Promise { - await this.backend.deleteSession(id); - } - - /** - * Update session title. - */ - async updateTitle(id: string, title: string): Promise { - await this.backend.updateTitle(id, title); - } - - /** - * Get storage quota information. - */ - async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { - return this.backend.getQuotaInfo(); - } - - /** - * Request persistent storage. - */ - async requestPersistence(): Promise { - return this.backend.requestPersistence(); - } -} diff --git a/packages/web-ui/src/storage/repositories/settings-repository.ts b/packages/web-ui/src/storage/repositories/settings-repository.ts deleted file mode 100644 index ad4a4659..00000000 --- a/packages/web-ui/src/storage/repositories/settings-repository.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { StorageBackend } from "../types.js"; - -/** - * Repository for simple application settings (proxy, theme, etc.). - * Uses a single backend for all settings. - */ -export class SettingsRepository { - constructor(private backend: StorageBackend) {} - - /** - * Get a setting value by key. - */ - async get(key: string): Promise { - return this.backend.get(key); - } - - /** - * Set a setting value. - */ - async set(key: string, value: T): Promise { - await this.backend.set(key, value); - } - - /** - * Delete a setting. - */ - async delete(key: string): Promise { - await this.backend.delete(key); - } - - /** - * Get all setting keys. - */ - async keys(): Promise { - return this.backend.keys(); - } - - /** - * Check if a setting exists. - */ - async has(key: string): Promise { - return this.backend.has(key); - } - - /** - * Clear all settings. - */ - async clear(): Promise { - await this.backend.clear(); - } -} diff --git a/packages/web-ui/src/storage/sessions-repository.ts b/packages/web-ui/src/storage/sessions-repository.ts new file mode 100644 index 00000000..399082e1 --- /dev/null +++ b/packages/web-ui/src/storage/sessions-repository.ts @@ -0,0 +1,62 @@ +import type { SessionData, SessionMetadata, StorageBackend } from "./types.js"; + +/** + * Repository for managing sessions using a multi-store backend. + * Handles session data and metadata with atomic operations. + */ +export class SessionsRepository { + constructor(private backend: StorageBackend) {} + + async saveSession(data: SessionData, metadata: SessionMetadata): Promise { + await this.backend.transaction(["sessions-metadata", "sessions-data"], "readwrite", async (tx) => { + await tx.set("sessions-metadata", metadata.id, metadata); + await tx.set("sessions-data", data.id, data); + }); + } + + async getSession(id: string): Promise { + return this.backend.get("sessions-data", id); + } + + async getMetadata(id: string): Promise { + return this.backend.get("sessions-metadata", id); + } + + async getAllMetadata(): Promise { + const keys = await this.backend.keys("sessions-metadata"); + const metadata = await Promise.all( + keys.map((key) => this.backend.get("sessions-metadata", key)), + ); + return metadata.filter((m): m is SessionMetadata => m !== null); + } + + async deleteSession(id: string): Promise { + await this.backend.transaction(["sessions-metadata", "sessions-data"], "readwrite", async (tx) => { + await tx.delete("sessions-metadata", id); + await tx.delete("sessions-data", id); + }); + } + + async updateTitle(id: string, title: string): Promise { + const metadata = await this.getMetadata(id); + if (metadata) { + metadata.title = title; + await this.backend.set("sessions-metadata", id, metadata); + } + + // Also update in full session data + const data = await this.getSession(id); + if (data) { + data.title = title; + await this.backend.set("sessions-data", id, data); + } + } + + async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> { + return this.backend.getQuotaInfo(); + } + + async requestPersistence(): Promise { + return this.backend.requestPersistence(); + } +} diff --git a/packages/web-ui/src/storage/types.ts b/packages/web-ui/src/storage/types.ts index 8a54da44..c1bf1df5 100644 --- a/packages/web-ui/src/storage/types.ts +++ b/packages/web-ui/src/storage/types.ts @@ -2,41 +2,82 @@ import type { Model } from "@mariozechner/pi-ai"; import type { ThinkingLevel } from "../agent/agent.js"; import type { AppMessage } from "../components/Messages.js"; +/** + * Transaction interface for atomic operations across stores. + */ +export interface StorageTransaction { + /** + * Get a value by key from a specific store. + */ + get(storeName: string, key: string): Promise; + + /** + * Set a value for a key in a specific store. + */ + set(storeName: string, key: string, value: T): Promise; + + /** + * Delete a key from a specific store. + */ + delete(storeName: string, key: string): Promise; +} + /** * Base interface for all storage backends. - * Provides a simple key-value storage abstraction that can be implemented - * by localStorage, IndexedDB, chrome.storage, or remote APIs. + * Multi-store key-value storage abstraction that can be implemented + * by IndexedDB, remote APIs, or any other multi-collection storage system. */ export interface StorageBackend { /** - * Get a value by key. Returns null if key doesn't exist. + * Get a value by key from a specific store. Returns null if key doesn't exist. */ - get(key: string): Promise; + get(storeName: string, key: string): Promise; /** - * Set a value for a key. + * Set a value for a key in a specific store. */ - set(key: string, value: T): Promise; + set(storeName: string, key: string, value: T): Promise; /** - * Delete a key. + * Delete a key from a specific store. */ - delete(key: string): Promise; + delete(storeName: string, key: string): Promise; /** - * Get all keys. + * Get all keys from a specific store, optionally filtered by prefix. */ - keys(): Promise; + keys(storeName: string, prefix?: string): Promise; /** - * Clear all data. + * Clear all data from a specific store. */ - clear(): Promise; + clear(storeName: string): Promise; /** - * Check if a key exists. + * Check if a key exists in a specific store. */ - has(key: string): Promise; + has(storeName: string, key: string): Promise; + + /** + * Execute atomic operations across multiple stores. + */ + transaction( + storeNames: string[], + mode: "readonly" | "readwrite", + operation: (tx: StorageTransaction) => Promise, + ): Promise; + + /** + * Get storage quota information. + * Used for warning users when approaching limits. + */ + getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>; + + /** + * Request persistent storage (prevents eviction). + * Returns true if granted, false otherwise. + */ + requestPersistence(): Promise; } /** @@ -121,67 +162,39 @@ export interface SessionData { } /** - * Backend interface for session storage. - * Implementations: IndexedDB (browser/extension), VSCode global state, etc. + * Configuration for IndexedDB backend. */ -export interface SessionStorageBackend { - /** - * Save both session data and metadata atomically. - * Should use transactions to ensure consistency. - */ - saveSession(data: SessionData, metadata: SessionMetadata): Promise; - - /** - * Get full session data by ID. - * Returns null if session doesn't exist. - */ - getSession(id: string): Promise; - - /** - * Get session metadata by ID. - * Returns null if session doesn't exist. - */ - getMetadata(id: string): Promise; - - /** - * Get all session metadata (for listing/searching). - * Should be efficient - metadata is small (~2KB each). - */ - getAllMetadata(): Promise; - - /** - * Delete a session (both data and metadata). - * Should use transactions to ensure both are deleted. - */ - deleteSession(id: string): Promise; - - /** - * Update session title (in both data and metadata). - * Optimized operation - no need to save full session. - */ - updateTitle(id: string, title: string): Promise; - - /** - * Get storage quota information. - * Used for warning users when approaching limits. - */ - getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }>; - - /** - * Request persistent storage (prevents eviction). - * Returns true if granted, false otherwise. - */ - requestPersistence(): Promise; +export interface IndexedDBConfig { + /** Database name */ + dbName: string; + /** Database version */ + version: number; + /** Object stores to create */ + stores: StoreConfig[]; } /** - * Options for configuring AppStorage. + * Configuration for an IndexedDB object store. */ -export interface AppStorageConfig { - /** Backend for simple settings (proxy, theme, etc.) */ - settings?: StorageBackend; - /** Backend for provider API keys */ - providerKeys?: StorageBackend; - /** Backend for sessions (optional - can be undefined if persistence not needed) */ - sessions?: SessionStorageBackend; +export interface StoreConfig { + /** Store name */ + name: string; + /** Key path (optional, for auto-extracting keys from objects) */ + keyPath?: string; + /** Auto-increment keys (optional) */ + autoIncrement?: boolean; + /** Indices to create on this store */ + indices?: IndexConfig[]; +} + +/** + * Configuration for an IndexedDB index. + */ +export interface IndexConfig { + /** Index name */ + name: string; + /** Key path to index on */ + keyPath: string; + /** Unique constraint (optional) */ + unique?: boolean; }