feat: add SQLite/PostgreSQL database switching support

This commit is contained in:
tinkle-community
2026-01-01 15:25:30 +08:00
parent 74adedbc64
commit d547863ebb
9 changed files with 442 additions and 49 deletions
+3
View File
@@ -14,6 +14,9 @@ import (
"nofx/manager"
"nofx/market"
"nofx/provider/alpaca"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"nofx/provider/twelvedata"
"nofx/store"
"nofx/trader"
+1
View File
@@ -56,6 +56,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+2
View File
@@ -133,6 +133,8 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+18 -9
View File
@@ -36,21 +36,30 @@ func main() {
cfg := config.Get()
logger.Info("✅ Configuration loaded")
// Initialize database
// Default path is data/data.db to work with Docker volume mount (/app/data)
dbPath := "data/data.db"
// Initialize database from environment variables
// DB_TYPE: sqlite (default) or postgres
// For SQLite: DB_PATH (default: data/data.db)
// For PostgreSQL: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "data/data.db"
}
// For backward compatibility: command line arg overrides env var (SQLite only)
if len(os.Args) > 1 {
dbPath = os.Args[1]
os.Setenv("DB_PATH", dbPath)
}
// Ensure data directory exists
if dir := filepath.Dir(dbPath); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
logger.Errorf("Failed to create data directory: %v", err)
// Ensure data directory exists (for SQLite)
if os.Getenv("DB_TYPE") == "" || os.Getenv("DB_TYPE") == "sqlite" {
if dir := filepath.Dir(dbPath); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
logger.Errorf("Failed to create data directory: %v", err)
}
}
}
logger.Infof("📋 Initializing database: %s", dbPath)
st, err := store.New(dbPath)
logger.Info("📋 Initializing database...")
st, err := store.NewFromEnv()
if err != nil {
logger.Fatalf("❌ Failed to initialize database: %v", err)
}
+240
View File
@@ -0,0 +1,240 @@
// Package store provides database driver abstraction
package store
import (
"database/sql"
"fmt"
"os"
"strings"
_ "github.com/lib/pq" // PostgreSQL driver
_ "modernc.org/sqlite" // SQLite driver
)
// DBType represents database type
type DBType string
const (
DBTypeSQLite DBType = "sqlite"
DBTypePostgres DBType = "postgres"
)
// DBConfig database configuration
type DBConfig struct {
Type DBType // sqlite or postgres
Path string // SQLite file path (for sqlite)
Host string // PostgreSQL host (for postgres)
Port int // PostgreSQL port (for postgres)
User string // PostgreSQL user (for postgres)
Password string // PostgreSQL password (for postgres)
DBName string // PostgreSQL database name (for postgres)
SSLMode string // PostgreSQL SSL mode (for postgres)
}
// DBDriver database driver abstraction
type DBDriver struct {
Type DBType
db *sql.DB
}
// NewDBDriver creates database driver from config
func NewDBDriver(cfg DBConfig) (*DBDriver, error) {
var db *sql.DB
var err error
switch cfg.Type {
case DBTypeSQLite:
db, err = openSQLite(cfg.Path)
case DBTypePostgres:
db, err = openPostgres(cfg)
default:
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
}
if err != nil {
return nil, err
}
return &DBDriver{Type: cfg.Type, db: db}, nil
}
// NewDBDriverFromEnv creates database driver from environment variables
// DB_TYPE: sqlite (default) or postgres
// For SQLite: DB_PATH (default: data/data.db)
// For PostgreSQL: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE
func NewDBDriverFromEnv() (*DBDriver, error) {
dbType := DBType(strings.ToLower(getEnv("DB_TYPE", "sqlite")))
switch dbType {
case DBTypeSQLite:
path := getEnv("DB_PATH", "data/data.db")
return NewDBDriver(DBConfig{Type: DBTypeSQLite, Path: path})
case DBTypePostgres:
port := 5432
if p := os.Getenv("DB_PORT"); p != "" {
fmt.Sscanf(p, "%d", &port)
}
return NewDBDriver(DBConfig{
Type: DBTypePostgres,
Host: getEnv("DB_HOST", "localhost"),
Port: port,
User: getEnv("DB_USER", "postgres"),
Password: os.Getenv("DB_PASSWORD"),
DBName: getEnv("DB_NAME", "nofx"),
SSLMode: getEnv("DB_SSLMODE", "disable"),
})
default:
return nil, fmt.Errorf("unsupported DB_TYPE: %s (use 'sqlite' or 'postgres')", dbType)
}
}
// DB returns underlying database connection
func (d *DBDriver) DB() *sql.DB {
return d.db
}
// Close closes database connection
func (d *DBDriver) Close() error {
return d.db.Close()
}
// AutoIncrement returns auto-increment syntax for current database
func (d *DBDriver) AutoIncrement() string {
switch d.Type {
case DBTypePostgres:
return "SERIAL"
default:
return "INTEGER PRIMARY KEY AUTOINCREMENT"
}
}
// Placeholder returns placeholder for parameterized queries
// SQLite uses ?, PostgreSQL uses $1, $2, etc.
func (d *DBDriver) Placeholder(index int) string {
switch d.Type {
case DBTypePostgres:
return fmt.Sprintf("$%d", index)
default:
return "?"
}
}
// ConvertPlaceholders converts ? placeholders to database-specific format
func (d *DBDriver) ConvertPlaceholders(query string) string {
if d.Type != DBTypePostgres {
return query
}
// Convert ? to $1, $2, etc. for PostgreSQL
result := query
index := 1
for strings.Contains(result, "?") {
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
index++
}
return result
}
// TableExists checks if a table exists
func (d *DBDriver) TableExists(tableName string) (bool, error) {
var exists bool
var query string
switch d.Type {
case DBTypePostgres:
query = `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)`
default:
query = `SELECT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = ?
)`
}
query = d.ConvertPlaceholders(query)
err := d.db.QueryRow(query, tableName).Scan(&exists)
return exists, err
}
// UpsertSyntax returns the upsert syntax for current database
// SQLite: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
// PostgreSQL: INSERT ... ON CONFLICT(...) DO UPDATE SET ...
// Both use the same syntax in modern versions
func (d *DBDriver) UpsertSyntax() string {
return "ON CONFLICT"
}
// openSQLite opens SQLite database
func openSQLite(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
}
// SQLite configuration
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
// Enable foreign key constraints
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Use DELETE mode for Docker compatibility
if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set journal_mode: %w", err)
}
// Set synchronous=FULL
if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set synchronous: %w", err)
}
// Set busy_timeout
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
}
return db, nil
}
// openPostgres opens PostgreSQL database
func openPostgres(cfg DBConfig) (*sql.DB, error) {
connStr := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode,
)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
}
// PostgreSQL configuration
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err)
}
return db, nil
}
// getEnv gets environment variable with default value
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
+52 -38
View File
@@ -7,13 +7,12 @@ import (
"fmt"
"nofx/logger"
"sync"
_ "modernc.org/sqlite"
)
// Store unified data storage interface
type Store struct {
db *sql.DB
db *sql.DB
driver *DBDriver // Database driver for abstraction
// Sub-stores (lazy initialization)
user *UserStore
@@ -34,57 +33,56 @@ type Store struct {
mu sync.RWMutex
}
// New creates new Store instance
// New creates new Store instance (SQLite mode for backward compatibility)
func New(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
driver, err := NewDBDriver(DBConfig{Type: DBTypeSQLite, Path: dbPath})
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// SQLite configuration
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
// Enable foreign key constraints
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
// Use DELETE mode (traditional mode) to ensure Docker bind mount compatibility
// Note: WAL mode causes data sync issues on macOS Docker
if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set journal_mode: %w", err)
}
// Set synchronous=FULL
if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set synchronous: %w", err)
}
// Set busy_timeout
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
}
s := &Store{db: db}
s := &Store{db: driver.DB(), driver: driver}
// Initialize all table structures
if err := s.initTables(); err != nil {
db.Close()
driver.Close()
return nil, fmt.Errorf("failed to initialize table structure: %w", err)
}
// Initialize default data
if err := s.initDefaultData(); err != nil {
db.Close()
driver.Close()
return nil, fmt.Errorf("failed to initialize default data: %w", err)
}
logger.Info("✅ Database enabled DELETE mode and FULL sync")
logger.Infof("✅ Database initialized (type: %s)", driver.Type)
return s, nil
}
// NewFromEnv creates new Store instance from environment variables
// DB_TYPE: sqlite (default) or postgres
// For SQLite: DB_PATH (default: data/data.db)
// For PostgreSQL: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE
func NewFromEnv() (*Store, error) {
driver, err := NewDBDriverFromEnv()
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
s := &Store{db: driver.DB(), driver: driver}
// Initialize all table structures
if err := s.initTables(); err != nil {
driver.Close()
return nil, fmt.Errorf("failed to initialize table structure: %w", err)
}
// Initialize default data
if err := s.initDefaultData(); err != nil {
driver.Close()
return nil, fmt.Errorf("failed to initialize default data: %w", err)
}
logger.Infof("✅ Database initialized (type: %s)", driver.Type)
return s, nil
}
@@ -293,9 +291,25 @@ func (s *Store) Order() *OrderStore {
// Close closes database connection
func (s *Store) Close() error {
if s.driver != nil {
return s.driver.Close()
}
return s.db.Close()
}
// Driver returns database driver for abstraction
func (s *Store) Driver() *DBDriver {
return s.driver
}
// DBType returns current database type
func (s *Store) DBType() DBType {
if s.driver != nil {
return s.driver.Type
}
return DBTypeSQLite
}
// DB gets underlying database connection (for legacy code compatibility, gradually deprecated)
// Deprecated: use Store methods instead
func (s *Store) DB() *sql.DB {
@@ -54,10 +54,10 @@ export default function AgentGrid() {
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
<div>
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs mb-2 tracking-widest uppercase">
<Crosshair className="w-4 h-4" /> Operator Select
<Crosshair className="w-4 h-4" /> MARKET SELECT
</div>
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
Available <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">Units</span>
STRATEGY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">UNITS</span>
</h2>
</div>
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
@@ -0,0 +1,121 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Terminal, Copy, Check, ChevronRight, Server, Command, Shield } from 'lucide-react'
export default function DeploymentHub() {
const [copied, setCopied] = useState(false)
const installCmd = "curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash"
const handleCopy = () => {
navigator.clipboard.writeText(installCmd)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<section className="py-24 bg-black relative overflow-hidden border-t border-zinc-800">
{/* Background Grids */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]"></div>
<div className="max-w-7xl mx-auto px-6 relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
{/* Left Column: Context */}
<div className="space-y-8">
<div className="flex items-center gap-2 text-nofx-gold font-mono text-xs tracking-[0.2em] uppercase">
<Server className="w-4 h-4" /> System Deployment
</div>
<h2 className="text-4xl md:text-6xl font-black text-white leading-tight">
DEPLOY <span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">INSTANTLY</span>
</h2>
<p className="text-zinc-400 text-lg leading-relaxed font-light">
Initialize your own high-frequency trading node in seconds.
Our optimized installer handles all dependencies, bringing your autonomous agent online with a single command.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
{[
{ icon: Command, label: "One-Line Install", desc: "No configuration needed" },
{ icon: Shield, label: "Secure Core", desc: "Sandboxed execution env" }
].map((item, i) => (
<div key={i} className="flex gap-4 items-start p-4 rounded bg-zinc-900/50 border border-zinc-800 hover:border-nofx-gold/30 transition-colors group">
<div className="p-2 rounded bg-black border border-zinc-800 text-nofx-gold group-hover:bg-nofx-gold/10 transition-colors">
<item.icon className="w-5 h-5" />
</div>
<div>
<h4 className="text-white font-bold font-mono text-sm mb-1">{item.label}</h4>
<p className="text-zinc-500 text-xs">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
{/* Right Column: Terminal */}
<motion.div
initial={{ opacity: 0, x: 50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
className="relative"
>
{/* Glow effect */}
<div className="absolute -inset-1 bg-gradient-to-r from-nofx-gold/20 to-blue-500/20 rounded-xl blur-xl opacity-50"></div>
<div className="relative rounded-xl overflow-hidden bg-[#0a0a0a] border border-zinc-800 shadow-2xl">
{/* Terminal Header */}
<div className="flex items-center justify-between px-4 py-3 bg-zinc-900/80 border-b border-zinc-800">
<div className="flex gap-2">
<div className="w-3 h-3 rounded-full bg-red-500/80"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500/80"></div>
<div className="w-3 h-3 rounded-full bg-green-500/80"></div>
</div>
<div className="text-[10px] font-mono text-zinc-500 flex items-center gap-1.5">
<Terminal className="w-3 h-3" />
root@nofx-os:~
</div>
</div>
{/* Terminal Content */}
<div className="p-8 font-mono text-sm md:text-base bg-black/50 backdrop-blur-sm min-h-[200px] flex flex-col justify-center">
<div className="mb-2 text-zinc-500 text-xs tracking-wide"># Initialize NoFX Core Protocol</div>
<div
className="group relative flex items-start gap-3 p-4 rounded-lg bg-zinc-900/50 border border-zinc-800 hover:border-nofx-gold/50 cursor-pointer transition-all hover:bg-zinc-900/80"
onClick={handleCopy}
>
<span className="text-nofx-gold mt-1"><ChevronRight className="w-4 h-4" /></span>
<code className="text-zinc-100 flex-1 break-all">
{installCmd}
</code>
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
<AnimatePresence mode='wait'>
{copied ? (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
className="flex items-center gap-1 text-green-400 bg-green-400/10 px-2 py-1 rounded text-xs font-bold"
>
<Check className="w-3 h-3" />
</motion.div>
) : (
<div className="text-zinc-400 bg-zinc-800 p-1.5 rounded hover:text-white hover:bg-zinc-700">
<Copy className="w-4 h-4" />
</div>
)}
</AnimatePresence>
</div>
</div>
<div className="mt-4 flex gap-2">
<div className="w-2 h-4 bg-nofx-gold animate-pulse"></div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
)
}
+3
View File
@@ -5,6 +5,7 @@ import FooterSection from '../components/landing/FooterSection'
import TerminalHero from '../components/landing/core/TerminalHero'
import LiveFeed from '../components/landing/core/LiveFeed'
import AgentGrid from '../components/landing/core/AgentGrid'
import DeploymentHub from '../components/landing/core/DeploymentHub'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
@@ -42,6 +43,8 @@ export function LandingPage() {
<AgentGrid />
<DeploymentHub />
<FooterSection language={language} />
{showLoginModal && (