package infra import ( "database/sql" "encoding/json" "fmt" "sync" "time" _ "github.com/mattn/go-sqlite3" ) // SQLiteCache es un cache key-value persistido en SQLite con soporte de TTL. // Valores almacenados como JSON serializado. El caller es responsable de // deserializar el []byte retornado por Get. // Seguro para uso concurrente. type SQLiteCache struct { db *sql.DB namespace string mu sync.Mutex } const sqliteCacheSchema = ` CREATE TABLE IF NOT EXISTS cache ( namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, created_at REAL NOT NULL, expires_at REAL, PRIMARY KEY (namespace, key) );` // CacheToSQLite abre (o crea) una base de datos SQLite en dbPath y retorna // un SQLiteCache para el namespace dado. func CacheToSQLite(dbPath, namespace string) (*SQLiteCache, error) { db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL") if err != nil { return nil, fmt.Errorf("cache_to_sqlite: open db: %w", err) } if _, err := db.Exec(sqliteCacheSchema); err != nil { db.Close() return nil, fmt.Errorf("cache_to_sqlite: create schema: %w", err) } return &SQLiteCache{db: db, namespace: namespace}, nil } // evictExpired elimina las entradas expiradas del namespace. Debe llamarse // con el mutex ya tomado. func (c *SQLiteCache) evictExpired() { now := float64(time.Now().UnixNano()) / 1e9 c.db.Exec( "DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?", c.namespace, now, ) } // Get retorna el valor asociado a key y true, o nil y false si no existe o // esta expirado. El []byte contiene JSON que el caller puede deserializar. func (c *SQLiteCache) Get(key string) ([]byte, bool) { c.mu.Lock() defer c.mu.Unlock() c.evictExpired() var value string err := c.db.QueryRow( "SELECT value FROM cache WHERE namespace = ? AND key = ?", c.namespace, key, ).Scan(&value) if err != nil { return nil, false } return []byte(value), true } // Set almacena value (JSON bytes) bajo key. ttl=0 significa sin expiracion. func (c *SQLiteCache) Set(key string, value []byte, ttl time.Duration) error { c.mu.Lock() defer c.mu.Unlock() now := float64(time.Now().UnixNano()) / 1e9 var expiresAt any if ttl > 0 { expiresAt = now + ttl.Seconds() } _, err := c.db.Exec( `INSERT INTO cache (namespace, key, value, created_at, expires_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(namespace, key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at, expires_at = excluded.expires_at`, c.namespace, key, string(value), now, expiresAt, ) if err != nil { return fmt.Errorf("cache set: %w", err) } return nil } // Delete elimina la entrada asociada a key. Retorna error si falla la query. func (c *SQLiteCache) Delete(key string) error { c.mu.Lock() defer c.mu.Unlock() _, err := c.db.Exec( "DELETE FROM cache WHERE namespace = ? AND key = ?", c.namespace, key, ) if err != nil { return fmt.Errorf("cache delete: %w", err) } return nil } // Clear elimina todas las entradas del namespace. Retorna el numero de filas // eliminadas. func (c *SQLiteCache) Clear() (int64, error) { c.mu.Lock() defer c.mu.Unlock() res, err := c.db.Exec( "DELETE FROM cache WHERE namespace = ?", c.namespace, ) if err != nil { return 0, fmt.Errorf("cache clear: %w", err) } n, _ := res.RowsAffected() return n, nil } // GetOrSet retorna el valor cacheado o llama factory() para obtenerlo, // lo almacena con el ttl dado y lo retorna. func (c *SQLiteCache) GetOrSet(key string, factory func() ([]byte, error), ttl time.Duration) ([]byte, error) { if v, ok := c.Get(key); ok { return v, nil } value, err := factory() if err != nil { return nil, fmt.Errorf("cache get_or_set factory: %w", err) } if err := c.Set(key, value, ttl); err != nil { return nil, err } return value, nil } // SetJSON serializa v como JSON y lo almacena bajo key. func (c *SQLiteCache) SetJSON(key string, v any, ttl time.Duration) error { b, err := json.Marshal(v) if err != nil { return fmt.Errorf("cache set_json marshal: %w", err) } return c.Set(key, b, ttl) } // Close cierra la conexion a la base de datos. func (c *SQLiteCache) Close() error { return c.db.Close() }