mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Perhaps it could use sqlite on disk if possible, else use indexeddb
This commit is contained in:
320
STORAGE.md
Normal file
320
STORAGE.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
257
src/components/StorageSettingsPanel.tsx
Normal file
257
src/components/StorageSettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/hooks/use-unified-storage.ts
Normal file
153
src/hooks/use-unified-storage.ts
Normal 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
439
src/lib/unified-storage.ts
Normal 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()
|
||||
Reference in New Issue
Block a user