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:
Mario Zechner 2025-10-08 16:14:29 +02:00
parent b3cce7b400
commit bbbc232c7c
19 changed files with 421 additions and 998 deletions

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}