mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 19:05:11 +00:00
Implement unified storage architecture
- Replace fragmented storage backends with single IndexedDBStorageBackend - Create multi-store StorageBackend interface (storeName parameter) - Remove old backends: IndexedDBBackend, LocalStorageBackend, SessionIndexedDBBackend, WebExtensionStorageBackend - Remove old repositories: ProviderKeysRepository, SessionRepository, SettingsRepository - Simplify AppStorage to directly expose storage methods (getSetting/setSetting, getProviderKey/setProviderKey) - Create SessionsRepository for session-specific operations - Update all consumers to use new simplified API - Update example app to use new storage architecture - Benefits: 10GB+ quota (vs 10MB chrome.storage), single database, consistent API
This commit is contained in:
parent
b3cce7b400
commit
bbbc232c7c
19 changed files with 421 additions and 998 deletions
|
|
@ -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<T>(key: string): Promise<T | null> {
|
||||
return this.backend.get("settings", key);
|
||||
}
|
||||
|
||||
// Session storage is optional
|
||||
if (config.sessions) {
|
||||
this.sessions = new SessionRepository(config.sessions);
|
||||
}
|
||||
async setSetting<T>(key: string, value: T): Promise<void> {
|
||||
await this.backend.set("settings", key, value);
|
||||
}
|
||||
|
||||
async deleteSetting(key: string): Promise<void> {
|
||||
await this.backend.delete("settings", key);
|
||||
}
|
||||
|
||||
async listSettings(): Promise<string[]> {
|
||||
return this.backend.keys("settings");
|
||||
}
|
||||
|
||||
// Provider keys access (delegates to "provider-keys" store)
|
||||
async getProviderKey(provider: string): Promise<string | null> {
|
||||
return this.backend.get("provider-keys", provider);
|
||||
}
|
||||
|
||||
async setProviderKey(provider: string, key: string): Promise<void> {
|
||||
await this.backend.set("provider-keys", provider, key);
|
||||
}
|
||||
|
||||
async deleteProviderKey(provider: string): Promise<void> {
|
||||
await this.backend.delete("provider-keys", provider);
|
||||
}
|
||||
|
||||
async listProviderKeys(): Promise<string[]> {
|
||||
return this.backend.keys("provider-keys");
|
||||
}
|
||||
|
||||
// Quota management
|
||||
async getQuotaInfo(): Promise<{ usage: number; quota: number; percent: number }> {
|
||||
return this.backend.getQuotaInfo();
|
||||
}
|
||||
|
||||
async requestPersistence(): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IDBDatabase> | null = null;
|
||||
|
||||
constructor(
|
||||
private dbName: string,
|
||||
private storeName: string = "keyvalue",
|
||||
) {}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
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<T = unknown>(key: string): Promise<T | null> {
|
||||
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<T = unknown>(key: string, value: T): Promise<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const value = await this.get(key);
|
||||
return value !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<IDBDatabase> | null = null;
|
||||
|
||||
constructor(private config: IndexedDBConfig) {}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
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<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async get<T = unknown>(storeName: string, key: string): Promise<T | null> {
|
||||
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<T = unknown>(storeName: string, key: string, value: T): Promise<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<T>(
|
||||
storeNames: string[],
|
||||
mode: "readonly" | "readwrite",
|
||||
operation: (tx: StorageTransaction) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const db = await this.getDB();
|
||||
const idbTx = db.transaction(storeNames, mode);
|
||||
|
||||
const storageTx: StorageTransaction = {
|
||||
get: async <T>(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 <T>(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<boolean> {
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
return await navigator.storage.persist();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = unknown>(key: string): Promise<T | null> {
|
||||
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<T = unknown>(key: string, value: T): Promise<void> {
|
||||
const fullKey = this.getKey(key);
|
||||
const serialized = JSON.stringify(value);
|
||||
localStorage.setItem(fullKey, serialized);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const fullKey = this.getKey(key);
|
||||
localStorage.removeItem(fullKey);
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const fullKey = this.getKey(key);
|
||||
return localStorage.getItem(fullKey) !== 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<IDBDatabase> | null = null;
|
||||
private readonly DB_NAME: string;
|
||||
private readonly DB_VERSION = 1;
|
||||
|
||||
constructor(dbName = "pi-sessions") {
|
||||
this.DB_NAME = dbName;
|
||||
}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
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<void> {
|
||||
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<SessionData | null> {
|
||||
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<SessionMetadata | null> {
|
||||
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<SessionMetadata[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = unknown>(key: string): Promise<T | null> {
|
||||
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<T = unknown>(key: string, value: T): Promise<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const value = await this.get(key);
|
||||
return value !== 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<string | null> {
|
||||
return this.backend.get<string>(`key:${provider}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the API key for a provider.
|
||||
*/
|
||||
async setKey(provider: string, key: string): Promise<void> {
|
||||
await this.backend.set(`key:${provider}`, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the API key for a provider.
|
||||
*/
|
||||
async removeKey(provider: string): Promise<void> {
|
||||
await this.backend.delete(`key:${provider}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all providers that have keys stored.
|
||||
*/
|
||||
async getProviders(): Promise<string[]> {
|
||||
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<boolean> {
|
||||
return this.backend.has(`key:${provider}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored API keys.
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
const providers = await this.getProviders();
|
||||
for (const provider of providers) {
|
||||
await this.removeKey(provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<SessionData | null> {
|
||||
return this.backend.getSession(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session metadata, sorted by lastModified descending.
|
||||
*/
|
||||
async listSessions(): Promise<SessionMetadata[]> {
|
||||
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<string | undefined> {
|
||||
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<SessionMetadata[]> {
|
||||
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<SessionMetadata | null> {
|
||||
return this.backend.getMetadata(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session.
|
||||
*/
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
await this.backend.deleteSession(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session title.
|
||||
*/
|
||||
async updateTitle(id: string, title: string): Promise<void> {
|
||||
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<boolean> {
|
||||
return this.backend.requestPersistence();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = unknown>(key: string): Promise<T | null> {
|
||||
return this.backend.get<T>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value.
|
||||
*/
|
||||
async set<T = unknown>(key: string, value: T): Promise<void> {
|
||||
await this.backend.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a setting.
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.backend.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all setting keys.
|
||||
*/
|
||||
async keys(): Promise<string[]> {
|
||||
return this.backend.keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a setting exists.
|
||||
*/
|
||||
async has(key: string): Promise<boolean> {
|
||||
return this.backend.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all settings.
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.backend.clear();
|
||||
}
|
||||
}
|
||||
62
packages/web-ui/src/storage/sessions-repository.ts
Normal file
62
packages/web-ui/src/storage/sessions-repository.ts
Normal file
|
|
@ -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<void> {
|
||||
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<SessionData | null> {
|
||||
return this.backend.get("sessions-data", id);
|
||||
}
|
||||
|
||||
async getMetadata(id: string): Promise<SessionMetadata | null> {
|
||||
return this.backend.get("sessions-metadata", id);
|
||||
}
|
||||
|
||||
async getAllMetadata(): Promise<SessionMetadata[]> {
|
||||
const keys = await this.backend.keys("sessions-metadata");
|
||||
const metadata = await Promise.all(
|
||||
keys.map((key) => this.backend.get<SessionMetadata>("sessions-metadata", key)),
|
||||
);
|
||||
return metadata.filter((m): m is SessionMetadata => m !== null);
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
return this.backend.requestPersistence();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = unknown>(storeName: string, key: string): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set a value for a key in a specific store.
|
||||
*/
|
||||
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a key from a specific store.
|
||||
*/
|
||||
delete(storeName: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T = unknown>(key: string): Promise<T | null>;
|
||||
get<T = unknown>(storeName: string, key: string): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set a value for a key.
|
||||
* Set a value for a key in a specific store.
|
||||
*/
|
||||
set<T = unknown>(key: string, value: T): Promise<void>;
|
||||
set<T = unknown>(storeName: string, key: string, value: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a key.
|
||||
* Delete a key from a specific store.
|
||||
*/
|
||||
delete(key: string): Promise<void>;
|
||||
delete(storeName: string, key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all keys.
|
||||
* Get all keys from a specific store, optionally filtered by prefix.
|
||||
*/
|
||||
keys(): Promise<string[]>;
|
||||
keys(storeName: string, prefix?: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Clear all data.
|
||||
* Clear all data from a specific store.
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
clear(storeName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a key exists.
|
||||
* Check if a key exists in a specific store.
|
||||
*/
|
||||
has(key: string): Promise<boolean>;
|
||||
has(storeName: string, key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Execute atomic operations across multiple stores.
|
||||
*/
|
||||
transaction<T>(
|
||||
storeNames: string[],
|
||||
mode: "readonly" | "readwrite",
|
||||
operation: (tx: StorageTransaction) => Promise<T>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* 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<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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<void>;
|
||||
|
||||
/**
|
||||
* Get full session data by ID.
|
||||
* Returns null if session doesn't exist.
|
||||
*/
|
||||
getSession(id: string): Promise<SessionData | null>;
|
||||
|
||||
/**
|
||||
* Get session metadata by ID.
|
||||
* Returns null if session doesn't exist.
|
||||
*/
|
||||
getMetadata(id: string): Promise<SessionMetadata | null>;
|
||||
|
||||
/**
|
||||
* Get all session metadata (for listing/searching).
|
||||
* Should be efficient - metadata is small (~2KB each).
|
||||
*/
|
||||
getAllMetadata(): Promise<SessionMetadata[]>;
|
||||
|
||||
/**
|
||||
* Delete a session (both data and metadata).
|
||||
* Should use transactions to ensure both are deleted.
|
||||
*/
|
||||
deleteSession(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update session title (in both data and metadata).
|
||||
* Optimized operation - no need to save full session.
|
||||
*/
|
||||
updateTitle(id: string, title: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 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<boolean>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue