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

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()