diff --git a/src/lib/unified-storage-adapters/flask-backend-adapter.ts b/src/lib/unified-storage-adapters/flask-backend-adapter.ts new file mode 100644 index 0000000..28bfdd0 --- /dev/null +++ b/src/lib/unified-storage-adapters/flask-backend-adapter.ts @@ -0,0 +1,77 @@ +import type { StorageAdapter } from './types' + +export class FlaskBackendAdapter implements StorageAdapter { + private baseUrl: string + private readonly TIMEOUT_MS = 2000 + + constructor(baseUrl?: string) { + this.baseUrl = baseUrl || localStorage.getItem('codeforge-flask-url') || import.meta.env.VITE_FLASK_BACKEND_URL || 'http://localhost:5001' + } + + private async request(endpoint: string, options?: RequestInit): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS) + + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) + throw new Error(error.error || `HTTP ${response.status}`) + } + + return response.json() + } catch (error: any) { + clearTimeout(timeoutId) + if (error.name === 'AbortError') { + throw new Error(`Request timeout after ${this.TIMEOUT_MS}ms`) + } + throw error + } + } + + async get(key: string): Promise { + try { + const result = await this.request<{ value: T }>(`/api/storage/${encodeURIComponent(key)}`) + return result.value + } catch (error: any) { + if (error.message?.includes('404') || error.message?.includes('not found')) { + return undefined + } + throw error + } + } + + async set(key: string, value: T): Promise { + await this.request(`/api/storage/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ value }), + }) + } + + async delete(key: string): Promise { + await this.request(`/api/storage/${encodeURIComponent(key)}`, { + method: 'DELETE', + }) + } + + async keys(): Promise { + const result = await this.request<{ keys: string[] }>('/api/storage/keys') + return result.keys + } + + async clear(): Promise { + await this.request('/api/storage/clear', { + method: 'POST', + }) + } +} diff --git a/src/lib/unified-storage-adapters/index.ts b/src/lib/unified-storage-adapters/index.ts new file mode 100644 index 0000000..f4be7b8 --- /dev/null +++ b/src/lib/unified-storage-adapters/index.ts @@ -0,0 +1,5 @@ +export { FlaskBackendAdapter } from './flask-backend-adapter' +export { IndexedDBAdapter } from './indexeddb-adapter' +export { SparkKVAdapter } from './spark-kv-adapter' +export { SQLiteAdapter } from './sqlite-adapter' +export type { StorageAdapter, StorageBackend } from './types' diff --git a/src/lib/unified-storage-adapters/indexeddb-adapter.ts b/src/lib/unified-storage-adapters/indexeddb-adapter.ts new file mode 100644 index 0000000..5ee1780 --- /dev/null +++ b/src/lib/unified-storage-adapters/indexeddb-adapter.ts @@ -0,0 +1,110 @@ +import type { StorageAdapter } from './types' + +export class IndexedDBAdapter implements StorageAdapter { + private db: IDBDatabase | null = null + private readonly dbName = 'CodeForgeDB' + private readonly storeName = 'keyvalue' + private readonly version = 2 + + private async init(): Promise { + if (this.db) return + + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: 'key' }) + } + } + }) + } + + async get(key: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readonly') + const store = transaction.objectStore(this.storeName) + const request = store.get(key) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + const result = request.result + resolve(result ? result.value : undefined) + } + }) + } + + async set(key: string, value: T): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite') + const store = transaction.objectStore(this.storeName) + const request = store.put({ key, value }) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + } + + async delete(key: string): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.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 { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.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 as string[]) + }) + } + + async clear(): Promise { + await this.init() + if (!this.db) throw new Error('Database not initialized') + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(this.storeName, 'readwrite') + const store = transaction.objectStore(this.storeName) + const request = store.clear() + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve() + }) + } + + async close(): Promise { + if (this.db) { + this.db.close() + this.db = null + } + } +} diff --git a/src/lib/unified-storage-adapters/spark-kv-adapter.ts b/src/lib/unified-storage-adapters/spark-kv-adapter.ts new file mode 100644 index 0000000..b04eb19 --- /dev/null +++ b/src/lib/unified-storage-adapters/spark-kv-adapter.ts @@ -0,0 +1,29 @@ +import type { StorageAdapter } from './types' + +export class SparkKVAdapter implements StorageAdapter { + async get(key: string): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + return await window.spark.kv.get(key) + } + + async set(key: string, value: T): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + await window.spark.kv.set(key, value) + } + + async delete(key: string): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + await window.spark.kv.delete(key) + } + + async keys(): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + return await window.spark.kv.keys() + } + + async clear(): Promise { + if (!window.spark?.kv) throw new Error('Spark KV not available') + const allKeys = await window.spark.kv.keys() + await Promise.all(allKeys.map(key => window.spark.kv.delete(key))) + } +} diff --git a/src/lib/unified-storage-adapters/sqlite-adapter.ts b/src/lib/unified-storage-adapters/sqlite-adapter.ts new file mode 100644 index 0000000..552f880 --- /dev/null +++ b/src/lib/unified-storage-adapters/sqlite-adapter.ts @@ -0,0 +1,123 @@ +import type { StorageAdapter } from './types' + +export class SQLiteAdapter implements StorageAdapter { + private db: any = null + private SQL: any = null + private initPromise: Promise | null = null + + private async loadSQLiteWASM(): Promise { + const moduleName = 'sql.js' + try { + return await import(/* @vite-ignore */ moduleName) + } catch { + throw new Error(`${moduleName} not installed. Run: npm install ${moduleName}`) + } + } + + private async init(): Promise { + if (this.db) return + if (this.initPromise) return this.initPromise + + this.initPromise = (async () => { + try { + const sqlJsModule = await this.loadSQLiteWASM() + const initSqlJs = sqlJsModule.default + + this.SQL = await initSqlJs({ + locateFile: (file: string) => `https://sql.js.org/dist/${file}` + }) + + const data = localStorage.getItem('codeforge-sqlite-db') + if (data) { + const buffer = new Uint8Array(JSON.parse(data)) + this.db = new this.SQL.Database(buffer) + } else { + this.db = new this.SQL.Database() + } + + this.db.run(` + CREATE TABLE IF NOT EXISTS keyvalue ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `) + } catch (error) { + console.error('SQLite initialization failed:', error) + throw error + } + })() + + return this.initPromise + } + + private persist(): void { + if (!this.db) return + try { + const data = this.db.export() + const buffer = Array.from(data) + localStorage.setItem('codeforge-sqlite-db', JSON.stringify(buffer)) + } catch (error) { + console.error('Failed to persist SQLite database:', error) + } + } + + async get(key: string): Promise { + await this.init() + const stmt = this.db.prepare('SELECT value FROM keyvalue WHERE key = ?') + stmt.bind([key]) + + if (stmt.step()) { + const row = stmt.getAsObject() + stmt.free() + return JSON.parse(row.value as string) as T + } + + stmt.free() + return undefined + } + + async set(key: string, value: T): Promise { + await this.init() + this.db.run( + 'INSERT OR REPLACE INTO keyvalue (key, value) VALUES (?, ?)', + [key, JSON.stringify(value)] + ) + this.persist() + } + + async delete(key: string): Promise { + await this.init() + this.db.run('DELETE FROM keyvalue WHERE key = ?', [key]) + this.persist() + } + + async keys(): Promise { + await this.init() + const stmt = this.db.prepare('SELECT key FROM keyvalue') + const keys: string[] = [] + + while (stmt.step()) { + const row = stmt.getAsObject() + keys.push(row.key as string) + } + + stmt.free() + return keys + } + + async clear(): Promise { + await this.init() + this.db.run('DELETE FROM keyvalue') + this.persist() + } + + async close(): Promise { + if (this.db) { + this.persist() + this.db.close() + this.db = null + this.SQL = null + this.initPromise = null + } + } +} diff --git a/src/lib/unified-storage-adapters/types.ts b/src/lib/unified-storage-adapters/types.ts new file mode 100644 index 0000000..21c3ec3 --- /dev/null +++ b/src/lib/unified-storage-adapters/types.ts @@ -0,0 +1,10 @@ +export type StorageBackend = 'flask' | 'indexeddb' | 'sqlite' | 'sparkkv' + +export interface StorageAdapter { + get(key: string): Promise + set(key: string, value: T): Promise + delete(key: string): Promise + keys(): Promise + clear(): Promise + close?(): Promise +} diff --git a/src/lib/unified-storage.ts b/src/lib/unified-storage.ts index 4e61c41..d430886 100644 --- a/src/lib/unified-storage.ts +++ b/src/lib/unified-storage.ts @@ -1,350 +1,9 @@ /// -export type StorageBackend = 'flask' | 'indexeddb' | 'sqlite' | 'sparkkv' +import type { StorageAdapter, StorageBackend } from './unified-storage-adapters' +import { FlaskBackendAdapter, IndexedDBAdapter, SparkKVAdapter, SQLiteAdapter } from './unified-storage-adapters' -export interface StorageAdapter { - get(key: string): Promise - set(key: string, value: T): Promise - delete(key: string): Promise - keys(): Promise - clear(): Promise - close?(): Promise -} - -class FlaskBackendAdapter implements StorageAdapter { - private baseUrl: string - private readonly TIMEOUT_MS = 2000 - - constructor(baseUrl?: string) { - this.baseUrl = baseUrl || localStorage.getItem('codeforge-flask-url') || import.meta.env.VITE_FLASK_BACKEND_URL || 'http://localhost:5001' - } - - private async request(endpoint: string, options?: RequestInit): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS) - - try { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - signal: controller.signal, - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })) - throw new Error(error.error || `HTTP ${response.status}`) - } - - return response.json() - } catch (error: any) { - clearTimeout(timeoutId) - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${this.TIMEOUT_MS}ms`) - } - throw error - } - } - - async get(key: string): Promise { - try { - const result = await this.request<{ value: T }>(`/api/storage/${encodeURIComponent(key)}`) - return result.value - } catch (error: any) { - if (error.message?.includes('404') || error.message?.includes('not found')) { - return undefined - } - throw error - } - } - - async set(key: string, value: T): Promise { - await this.request(`/api/storage/${encodeURIComponent(key)}`, { - method: 'PUT', - body: JSON.stringify({ value }), - }) - } - - async delete(key: string): Promise { - await this.request(`/api/storage/${encodeURIComponent(key)}`, { - method: 'DELETE', - }) - } - - async keys(): Promise { - const result = await this.request<{ keys: string[] }>('/api/storage/keys') - return result.keys - } - - async clear(): Promise { - await this.request('/api/storage/clear', { - method: 'POST', - }) - } -} - -class IndexedDBAdapter implements StorageAdapter { - private db: IDBDatabase | null = null - private readonly dbName = 'CodeForgeDB' - private readonly storeName = 'keyvalue' - private readonly version = 2 - - private async init(): Promise { - if (this.db) return - - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, this.version) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - this.db = request.result - resolve() - } - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result - - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName, { keyPath: 'key' }) - } - } - }) - } - - async get(key: string): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readonly') - const store = transaction.objectStore(this.storeName) - const request = store.get(key) - - request.onerror = () => reject(request.error) - request.onsuccess = () => { - const result = request.result - resolve(result ? result.value : undefined) - } - }) - } - - async set(key: string, value: T): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readwrite') - const store = transaction.objectStore(this.storeName) - const request = store.put({ key, value }) - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async delete(key: string): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.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 { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.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 as string[]) - }) - } - - async clear(): Promise { - await this.init() - if (!this.db) throw new Error('Database not initialized') - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(this.storeName, 'readwrite') - const store = transaction.objectStore(this.storeName) - const request = store.clear() - - request.onerror = () => reject(request.error) - request.onsuccess = () => resolve() - }) - } - - async close(): Promise { - if (this.db) { - this.db.close() - this.db = null - } - } -} - -class SparkKVAdapter implements StorageAdapter { - async get(key: string): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - return await window.spark.kv.get(key) - } - - async set(key: string, value: T): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - await window.spark.kv.set(key, value) - } - - async delete(key: string): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - await window.spark.kv.delete(key) - } - - async keys(): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - return await window.spark.kv.keys() - } - - async clear(): Promise { - if (!window.spark?.kv) throw new Error('Spark KV not available') - const allKeys = await window.spark.kv.keys() - await Promise.all(allKeys.map(key => window.spark.kv.delete(key))) - } -} - -class SQLiteAdapter implements StorageAdapter { - private db: any = null - private SQL: any = null - private initPromise: Promise | null = null - - private async loadSQLiteWASM(): Promise { - const moduleName = 'sql.js' - try { - return await import(/* @vite-ignore */ moduleName) - } catch { - throw new Error(`${moduleName} not installed. Run: npm install ${moduleName}`) - } - } - - private async init(): Promise { - if (this.db) return - if (this.initPromise) return this.initPromise - - this.initPromise = (async () => { - try { - const sqlJsModule = await this.loadSQLiteWASM() - const initSqlJs = sqlJsModule.default - - this.SQL = await initSqlJs({ - locateFile: (file: string) => `https://sql.js.org/dist/${file}` - }) - - const data = localStorage.getItem('codeforge-sqlite-db') - if (data) { - const buffer = new Uint8Array(JSON.parse(data)) - this.db = new this.SQL.Database(buffer) - } else { - this.db = new this.SQL.Database() - } - - this.db.run(` - CREATE TABLE IF NOT EXISTS keyvalue ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - `) - } catch (error) { - console.error('SQLite initialization failed:', error) - throw error - } - })() - - return this.initPromise - } - - private persist(): void { - if (!this.db) return - try { - const data = this.db.export() - const buffer = Array.from(data) - localStorage.setItem('codeforge-sqlite-db', JSON.stringify(buffer)) - } catch (error) { - console.error('Failed to persist SQLite database:', error) - } - } - - async get(key: string): Promise { - await this.init() - const stmt = this.db.prepare('SELECT value FROM keyvalue WHERE key = ?') - stmt.bind([key]) - - if (stmt.step()) { - const row = stmt.getAsObject() - stmt.free() - return JSON.parse(row.value as string) as T - } - - stmt.free() - return undefined - } - - async set(key: string, value: T): Promise { - await this.init() - this.db.run( - 'INSERT OR REPLACE INTO keyvalue (key, value) VALUES (?, ?)', - [key, JSON.stringify(value)] - ) - this.persist() - } - - async delete(key: string): Promise { - await this.init() - this.db.run('DELETE FROM keyvalue WHERE key = ?', [key]) - this.persist() - } - - async keys(): Promise { - await this.init() - const stmt = this.db.prepare('SELECT key FROM keyvalue') - const keys: string[] = [] - - while (stmt.step()) { - const row = stmt.getAsObject() - keys.push(row.key as string) - } - - stmt.free() - return keys - } - - async clear(): Promise { - await this.init() - this.db.run('DELETE FROM keyvalue') - this.persist() - } - - async close(): Promise { - if (this.db) { - this.persist() - this.db.close() - this.db = null - this.SQL = null - this.initPromise = null - } - } -} +export type { StorageAdapter, StorageBackend } from './unified-storage-adapters' class UnifiedStorage { private adapter: StorageAdapter | null = null