Generated by Spark: Perhaps it could use sqlite on disk if possible, else use indexeddb

This commit is contained in:
2026-01-17 18:19:45 +00:00
committed by GitHub
parent 270d0be790
commit 02eb47e83f
5 changed files with 1173 additions and 1 deletions

320
STORAGE.md Normal file
View File

@@ -0,0 +1,320 @@
# Unified Storage System
CodeForge now features a unified storage system that automatically selects the best available storage backend for your data persistence needs.
## Storage Backends
The system supports three storage backends in order of preference:
### 1. **SQLite (Preferred)**
- **Type**: On-disk database via WASM
- **Persistence**: Data stored in browser localStorage as serialized SQLite database
- **Pros**:
- SQL query support
- Better performance for complex queries
- More robust data integrity
- Works offline
- **Cons**:
- Requires sql.js library (optional dependency)
- Slightly larger bundle size
- localStorage size limits (~5-10MB)
- **Installation**: `npm install sql.js`
### 2. **IndexedDB (Default)**
- **Type**: Browser-native key-value store
- **Persistence**: Data stored in browser IndexedDB
- **Pros**:
- No additional dependencies
- Large storage capacity (usually >50MB, can be GBs)
- Fast for simple key-value operations
- Works offline
- Native browser support
- **Cons**:
- No SQL query support
- More complex API
- Asynchronous only
### 3. **Spark KV (Fallback)**
- **Type**: Cloud key-value store
- **Persistence**: Data stored in Spark runtime
- **Pros**:
- No size limits
- Synced across devices
- Persistent beyond browser
- **Cons**:
- Requires Spark runtime
- Online only
- Slower than local storage
## Usage
### Basic Usage
```typescript
import { unifiedStorage } from '@/lib/unified-storage'
// Get data
const value = await unifiedStorage.get<MyType>('my-key')
// Set data
await unifiedStorage.set('my-key', myData)
// Delete data
await unifiedStorage.delete('my-key')
// Get all keys
const keys = await unifiedStorage.keys()
// Clear all data
await unifiedStorage.clear()
// Check current backend
const backend = await unifiedStorage.getBackend()
console.log(`Using: ${backend}`) // 'sqlite', 'indexeddb', or 'sparkkv'
```
### React Hook
```typescript
import { useUnifiedStorage } from '@/hooks/use-unified-storage'
function MyComponent() {
const [todos, setTodos, deleteTodos] = useUnifiedStorage('todos', [])
const addTodo = async (todo: Todo) => {
// ALWAYS use functional updates to avoid stale data
await setTodos((current) => [...current, todo])
}
const removeTodo = async (id: string) => {
await setTodos((current) => current.filter(t => t.id !== id))
}
return (
<div>
<button onClick={() => addTodo({ id: '1', text: 'New Todo' })}>
Add Todo
</button>
<button onClick={deleteTodos}>Clear All</button>
</div>
)
}
```
### Storage Backend Management
```typescript
import { useStorageBackend } from '@/hooks/use-unified-storage'
function StorageManager() {
const {
backend,
isLoading,
switchToSQLite,
switchToIndexedDB,
exportData,
importData,
} = useStorageBackend()
return (
<div>
<p>Current backend: {backend}</p>
<button onClick={switchToSQLite}>Switch to SQLite</button>
<button onClick={switchToIndexedDB}>Switch to IndexedDB</button>
<button onClick={async () => {
const data = await exportData()
console.log('Exported:', data)
}}>
Export Data
</button>
</div>
)
}
```
## Migration Between Backends
The system supports seamless migration between storage backends:
```typescript
// Migrate from IndexedDB to SQLite (preserves all data)
await unifiedStorage.switchToSQLite()
// Migrate from SQLite to IndexedDB (preserves all data)
await unifiedStorage.switchToIndexedDB()
```
When switching backends:
1. All existing data is exported from the current backend
2. The new backend is initialized
3. All data is imported into the new backend
4. The preference is saved for future sessions
## Data Export/Import
Export and import data for backup or migration purposes:
```typescript
// Export all data as JSON
const data = await unifiedStorage.exportData()
const json = JSON.stringify(data, null, 2)
// Save to file
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'codeforge-backup.json'
a.click()
// Import data from JSON
const imported = JSON.parse(jsonString)
await unifiedStorage.importData(imported)
```
## Backend Detection
The system automatically detects and selects the best available backend on initialization:
1. **SQLite** is attempted first if `localStorage.getItem('codeforge-prefer-sqlite') === 'true'`
2. **IndexedDB** is attempted next if available in the browser
3. **Spark KV** is used as a last resort fallback
You can check which backend is in use:
```typescript
const backend = await unifiedStorage.getBackend()
// Returns: 'sqlite' | 'indexeddb' | 'sparkkv' | null
```
## Performance Considerations
### SQLite
- Best for: Complex queries, relational data, large datasets
- Read: Fast (in-memory queries)
- Write: Moderate (requires serialization to localStorage)
- Capacity: Limited by localStorage (~5-10MB)
### IndexedDB
- Best for: Simple key-value storage, large data volumes
- Read: Very fast (optimized for key lookups)
- Write: Very fast (optimized browser API)
- Capacity: Large (typically 50MB+, can scale to GBs)
### Spark KV
- Best for: Cross-device sync, cloud persistence
- Read: Moderate (network latency)
- Write: Moderate (network latency)
- Capacity: Unlimited
## Troubleshooting
### SQLite Not Available
If SQLite fails to initialize:
1. Check console for errors
2. Ensure sql.js is installed: `npm install sql.js`
3. System will automatically fallback to IndexedDB
### IndexedDB Quota Exceeded
If IndexedDB storage is full:
1. Clear old data: `await unifiedStorage.clear()`
2. Export important data first
3. Consider switching to Spark KV for unlimited storage
### Data Not Persisting
1. Check which backend is active: `await unifiedStorage.getBackend()`
2. Verify browser supports storage (check if in private mode)
3. Check browser console for errors
4. Try exporting/importing data to refresh storage
## Best Practices
1. **Use Functional Updates**: Always use functional form of setState to avoid stale data:
```typescript
// ❌ WRONG - can lose data
setTodos([...todos, newTodo])
// ✅ CORRECT - always safe
setTodos((current) => [...current, newTodo])
```
2. **Handle Errors**: Wrap storage operations in try-catch:
```typescript
try {
await unifiedStorage.set('key', value)
} catch (error) {
console.error('Storage failed:', error)
toast.error('Failed to save data')
}
```
3. **Export Regularly**: Create backups of important data:
```typescript
const backup = await unifiedStorage.exportData()
// Save backup somewhere safe
```
4. **Use Appropriate Backend**: Choose based on your needs:
- Local-only, small data → IndexedDB
- Local-only, needs SQL → SQLite (install sql.js)
- Cloud sync needed → Spark KV
## UI Component
The app includes a `StorageSettingsPanel` component that provides a user-friendly interface for:
- Viewing current storage backend
- Switching between backends
- Exporting/importing data
- Viewing storage statistics
Add it to your settings page:
```typescript
import { StorageSettingsPanel } from '@/components/StorageSettingsPanel'
function SettingsPage() {
return (
<div>
<h1>Settings</h1>
<StorageSettingsPanel />
</div>
)
}
```
## Architecture
```
┌─────────────────────────────────────────┐
│ Unified Storage API │
│ (unifiedStorage.get/set/delete/keys) │
└──────────────┬──────────────────────────┘
├─ Automatic Backend Detection
┌───────┴───────┬─────────────┬────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌────────────┐ ┌─────────┐ ┌────┐
│ SQLite │ │ IndexedDB │ │Spark KV │ │ ? │
│ (optional) │ │ (default) │ │(fallback│ │Next│
└─────────────┘ └────────────┘ └─────────┘ └────┘
│ │ │
└───────┬───────┴─────────────┘
Browser Storage
```
## Future Enhancements
- [ ] Add compression for large data objects
- [ ] Implement automatic backup scheduling
- [ ] Add support for native file system API
- [ ] Support for WebSQL (legacy browsers)
- [ ] Encrypted storage option
- [ ] Storage analytics and usage metrics
- [ ] Automatic data migration on version changes

View File

@@ -7,6 +7,7 @@ import { Database, HardDrive, CloudArrowUp, CloudArrowDown, Trash, Info } from '
import { storage } from '@/lib/storage'
import { db } from '@/lib/db'
import { toast } from 'sonner'
import { StorageSettingsPanel } from './StorageSettingsPanel'
export function StorageSettings() {
const [isMigrating, setIsMigrating] = useState(false)
@@ -108,11 +109,13 @@ export function StorageSettings() {
</p>
</div>
<StorageSettingsPanel />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Info size={20} />
Storage Information
Legacy Storage Information
</CardTitle>
<CardDescription>
This application uses IndexedDB as the primary local database, with Spark KV as a

View File

@@ -0,0 +1,257 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useStorageBackend } from '@/hooks/use-unified-storage'
import { Database, HardDrive, Cloud, Download, Upload, CircleNotch } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { useState } from 'react'
export function StorageSettingsPanel() {
const {
backend,
isLoading,
switchToSQLite,
switchToIndexedDB,
exportData,
importData,
} = useStorageBackend()
const [isSwitching, setIsSwitching] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const handleSwitchToSQLite = async () => {
if (backend === 'sqlite') {
toast.info('Already using SQLite')
return
}
setIsSwitching(true)
try {
await switchToSQLite()
toast.success('Switched to SQLite storage')
} catch (error) {
toast.error(`Failed to switch to SQLite: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsSwitching(false)
}
}
const handleSwitchToIndexedDB = async () => {
if (backend === 'indexeddb') {
toast.info('Already using IndexedDB')
return
}
setIsSwitching(true)
try {
await switchToIndexedDB()
toast.success('Switched to IndexedDB storage')
} catch (error) {
toast.error(`Failed to switch to IndexedDB: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsSwitching(false)
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const data = await exportData()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `codeforge-data-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Data exported successfully')
} catch (error) {
toast.error(`Failed to export data: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsExporting(false)
}
}
const handleImport = async () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
setIsImporting(true)
try {
const text = await file.text()
const data = JSON.parse(text)
await importData(data)
toast.success('Data imported successfully')
} catch (error) {
toast.error(`Failed to import data: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsImporting(false)
}
}
input.click()
}
const getBackendIcon = () => {
switch (backend) {
case 'sqlite':
return <HardDrive className="w-5 h-5" />
case 'indexeddb':
return <Database className="w-5 h-5" />
case 'sparkkv':
return <Cloud className="w-5 h-5" />
default:
return <Database className="w-5 h-5" />
}
}
const getBackendLabel = () => {
switch (backend) {
case 'sqlite':
return 'SQLite (On-disk)'
case 'indexeddb':
return 'IndexedDB (Browser)'
case 'sparkkv':
return 'Spark KV (Cloud)'
default:
return 'Unknown'
}
}
const getBackendDescription = () => {
switch (backend) {
case 'sqlite':
return 'Data stored in SQLite database persisted to localStorage'
case 'indexeddb':
return 'Data stored in browser IndexedDB (recommended for most users)'
case 'sparkkv':
return 'Data stored in Spark cloud key-value store'
default:
return 'No storage backend detected'
}
}
if (isLoading && !backend) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Storage Settings
</CardTitle>
<CardDescription>Detecting storage backend...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<CircleNotch className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Storage Settings
</CardTitle>
<CardDescription>
Manage your local data storage preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Current Backend:</span>
{getBackendIcon()}
<span className="text-sm">{getBackendLabel()}</span>
</div>
<Badge variant="secondary">{backend?.toUpperCase() || 'UNKNOWN'}</Badge>
</div>
<p className="text-sm text-muted-foreground">{getBackendDescription()}</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium">Switch Storage Backend</h3>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleSwitchToSQLite}
disabled={backend === 'sqlite' || isSwitching}
variant={backend === 'sqlite' ? 'default' : 'outline'}
size="sm"
>
{isSwitching ? (
<CircleNotch className="w-4 h-4 mr-2 animate-spin" />
) : (
<HardDrive className="w-4 h-4 mr-2" />
)}
SQLite
</Button>
<Button
onClick={handleSwitchToIndexedDB}
disabled={backend === 'indexeddb' || isSwitching}
variant={backend === 'indexeddb' ? 'default' : 'outline'}
size="sm"
>
{isSwitching ? (
<CircleNotch className="w-4 h-4 mr-2 animate-spin" />
) : (
<Database className="w-4 h-4 mr-2" />
)}
IndexedDB
</Button>
</div>
<p className="text-xs text-muted-foreground">
Switching storage backends will migrate all existing data
</p>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium">Data Management</h3>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleExport}
disabled={isExporting}
variant="outline"
size="sm"
>
{isExporting ? (
<CircleNotch className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Export Data
</Button>
<Button
onClick={handleImport}
disabled={isImporting}
variant="outline"
size="sm"
>
{isImporting ? (
<CircleNotch className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
Import Data
</Button>
</div>
<p className="text-xs text-muted-foreground">
Export your data as a JSON file or import from a previous backup
</p>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,153 @@
import { useState, useEffect, useCallback } from 'react'
import { unifiedStorage } from '@/lib/unified-storage'
export function useUnifiedStorage<T>(
key: string,
defaultValue: T
): [T, (value: T | ((prev: T) => T)) => Promise<void>, () => Promise<void>] {
const [value, setValue] = useState<T>(defaultValue)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let mounted = true
const loadValue = async () => {
try {
const stored = await unifiedStorage.get<T>(key)
if (mounted) {
setValue(stored !== undefined ? stored : defaultValue)
}
} catch (error) {
console.error(`Failed to load ${key}:`, error)
if (mounted) {
setValue(defaultValue)
}
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
loadValue()
return () => {
mounted = false
}
}, [key, defaultValue])
const updateValue = useCallback(
async (newValue: T | ((prev: T) => T)) => {
try {
const valueToSet = typeof newValue === 'function'
? (newValue as (prev: T) => T)(value)
: newValue
setValue(valueToSet)
await unifiedStorage.set(key, valueToSet)
} catch (error) {
console.error(`Failed to save ${key}:`, error)
throw error
}
},
[key, value]
)
const deleteValue = useCallback(async () => {
try {
setValue(defaultValue)
await unifiedStorage.delete(key)
} catch (error) {
console.error(`Failed to delete ${key}:`, error)
throw error
}
}, [key, defaultValue])
return [value, updateValue, deleteValue]
}
export function useStorageBackend() {
const [backend, setBackend] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let mounted = true
const detectBackend = async () => {
try {
const currentBackend = await unifiedStorage.getBackend()
if (mounted) {
setBackend(currentBackend)
}
} catch (error) {
console.error('Failed to detect storage backend:', error)
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
detectBackend()
return () => {
mounted = false
}
}, [])
const switchToSQLite = useCallback(async () => {
setIsLoading(true)
try {
await unifiedStorage.switchToSQLite()
setBackend('sqlite')
} catch (error) {
console.error('Failed to switch to SQLite:', error)
throw error
} finally {
setIsLoading(false)
}
}, [])
const switchToIndexedDB = useCallback(async () => {
setIsLoading(true)
try {
await unifiedStorage.switchToIndexedDB()
setBackend('indexeddb')
} catch (error) {
console.error('Failed to switch to IndexedDB:', error)
throw error
} finally {
setIsLoading(false)
}
}, [])
const exportData = useCallback(async () => {
try {
return await unifiedStorage.exportData()
} catch (error) {
console.error('Failed to export data:', error)
throw error
}
}, [])
const importData = useCallback(async (data: Record<string, any>) => {
setIsLoading(true)
try {
await unifiedStorage.importData(data)
} catch (error) {
console.error('Failed to import data:', error)
throw error
} finally {
setIsLoading(false)
}
}, [])
return {
backend,
isLoading,
switchToSQLite,
switchToIndexedDB,
exportData,
importData,
}
}

439
src/lib/unified-storage.ts Normal file
View File

@@ -0,0 +1,439 @@
export type StorageBackend = 'sqlite' | 'indexeddb' | 'sparkkv'
export interface StorageAdapter {
get<T>(key: string): Promise<T | undefined>
set<T>(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
keys(): Promise<string[]>
clear(): Promise<void>
close?(): Promise<void>
}
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<void> {
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<T>(key: string): Promise<T | undefined> {
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<T>(key: string, value: T): Promise<void> {
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<void> {
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<string[]> {
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<void> {
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<void> {
if (this.db) {
this.db.close()
this.db = null
}
}
}
class SparkKVAdapter implements StorageAdapter {
async get<T>(key: string): Promise<T | undefined> {
if (!window.spark?.kv) throw new Error('Spark KV not available')
return await window.spark.kv.get<T>(key)
}
async set<T>(key: string, value: T): Promise<void> {
if (!window.spark?.kv) throw new Error('Spark KV not available')
await window.spark.kv.set(key, value)
}
async delete(key: string): Promise<void> {
if (!window.spark?.kv) throw new Error('Spark KV not available')
await window.spark.kv.delete(key)
}
async keys(): Promise<string[]> {
if (!window.spark?.kv) throw new Error('Spark KV not available')
return await window.spark.kv.keys()
}
async clear(): Promise<void> {
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<void> | null = null
private async loadSQLiteWASM(): Promise<any> {
const moduleName = 'sql.js'
try {
return await import(moduleName)
} catch {
throw new Error(`${moduleName} not installed. Run: npm install ${moduleName}`)
}
}
private async init(): Promise<void> {
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<T>(key: string): Promise<T | undefined> {
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<T>(key: string, value: T): Promise<void> {
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<void> {
await this.init()
this.db.run('DELETE FROM keyvalue WHERE key = ?', [key])
this.persist()
}
async keys(): Promise<string[]> {
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<void> {
await this.init()
this.db.run('DELETE FROM keyvalue')
this.persist()
}
async close(): Promise<void> {
if (this.db) {
this.persist()
this.db.close()
this.db = null
this.SQL = null
this.initPromise = null
}
}
}
class UnifiedStorage {
private adapter: StorageAdapter | null = null
private backend: StorageBackend | null = null
private initPromise: Promise<void> | null = null
private async detectAndInitialize(): Promise<void> {
if (this.adapter) return
if (this.initPromise) return this.initPromise
this.initPromise = (async () => {
const preferSQLite = localStorage.getItem('codeforge-prefer-sqlite') === 'true'
if (preferSQLite) {
try {
console.log('[Storage] Attempting to initialize SQLite...')
const sqliteAdapter = new SQLiteAdapter()
await sqliteAdapter.get('_health_check')
this.adapter = sqliteAdapter
this.backend = 'sqlite'
console.log('[Storage] ✓ Using SQLite')
return
} catch (error) {
console.warn('[Storage] SQLite not available:', error)
}
}
if (typeof indexedDB !== 'undefined') {
try {
console.log('[Storage] Attempting to initialize IndexedDB...')
const idbAdapter = new IndexedDBAdapter()
await idbAdapter.get('_health_check')
this.adapter = idbAdapter
this.backend = 'indexeddb'
console.log('[Storage] ✓ Using IndexedDB')
return
} catch (error) {
console.warn('[Storage] IndexedDB not available:', error)
}
}
if (window.spark?.kv) {
try {
console.log('[Storage] Attempting to initialize Spark KV...')
const sparkAdapter = new SparkKVAdapter()
await sparkAdapter.get('_health_check')
this.adapter = sparkAdapter
this.backend = 'sparkkv'
console.log('[Storage] ✓ Using Spark KV')
return
} catch (error) {
console.warn('[Storage] Spark KV not available:', error)
}
}
throw new Error('No storage backend available')
})()
return this.initPromise
}
async get<T>(key: string): Promise<T | undefined> {
await this.detectAndInitialize()
return this.adapter!.get<T>(key)
}
async set<T>(key: string, value: T): Promise<void> {
await this.detectAndInitialize()
await this.adapter!.set(key, value)
}
async delete(key: string): Promise<void> {
await this.detectAndInitialize()
await this.adapter!.delete(key)
}
async keys(): Promise<string[]> {
await this.detectAndInitialize()
return this.adapter!.keys()
}
async clear(): Promise<void> {
await this.detectAndInitialize()
await this.adapter!.clear()
}
async getBackend(): Promise<StorageBackend | null> {
await this.detectAndInitialize()
return this.backend
}
async switchToSQLite(): Promise<void> {
if (this.backend === 'sqlite') return
console.log('[Storage] Switching to SQLite...')
const oldKeys = await this.keys()
const data: Record<string, any> = {}
for (const key of oldKeys) {
data[key] = await this.get(key)
}
if (this.adapter?.close) {
await this.adapter.close()
}
this.adapter = null
this.backend = null
this.initPromise = null
localStorage.setItem('codeforge-prefer-sqlite', 'true')
await this.detectAndInitialize()
for (const [key, value] of Object.entries(data)) {
await this.set(key, value)
}
console.log('[Storage] ✓ Migrated to SQLite')
}
async switchToIndexedDB(): Promise<void> {
if (this.backend === 'indexeddb') return
console.log('[Storage] Switching to IndexedDB...')
const oldKeys = await this.keys()
const data: Record<string, any> = {}
for (const key of oldKeys) {
data[key] = await this.get(key)
}
if (this.adapter?.close) {
await this.adapter.close()
}
this.adapter = null
this.backend = null
this.initPromise = null
localStorage.removeItem('codeforge-prefer-sqlite')
await this.detectAndInitialize()
for (const [key, value] of Object.entries(data)) {
await this.set(key, value)
}
console.log('[Storage] ✓ Migrated to IndexedDB')
}
async exportData(): Promise<Record<string, any>> {
const allKeys = await this.keys()
const data: Record<string, any> = {}
for (const key of allKeys) {
data[key] = await this.get(key)
}
return data
}
async importData(data: Record<string, any>): Promise<void> {
for (const [key, value] of Object.entries(data)) {
await this.set(key, value)
}
}
}
export const unifiedStorage = new UnifiedStorage()