lib/fbe/middleware/sqlite_store.rb



# frozen_string_literal: true

# SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
# SPDX-License-Identifier: MIT

require 'filesize'
require 'json'
require 'loog'
require 'sqlite3'
require 'time'
require_relative '../../fbe'
require_relative '../../fbe/middleware'

# Persisted SQLite store for Faraday::HttpCache
#
# This class provides a persistent cache store backed by SQLite for use with
# Faraday::HttpCache middleware. It's designed to cache HTTP responses from
# GitHub API calls to reduce API rate limit consumption and improve performance.
#
# Key features:
# - Automatic version management to invalidate cache on version changes
# - Size-based cache eviction (configurable, defaults to 10MB)
# - Thread-safe SQLite transactions
# - JSON serialization for cached values
# - Filtering of non-cacheable requests (non-GET, URLs with query parameters)
#
# Usage example:
#   store = Fbe::Middleware::SqliteStore.new(
#     '/path/to/cache.db',
#     '1.0.0',
#     loog: logger,
#     maxsize: '50Mb'
#   )
#   # Use with Faraday
#   Faraday.new do |builder|
#     builder.use Faraday::HttpCache, store: store
#   end
#
# The store automatically manages the SQLite database schema and handles
# cleanup operations when the database grows too large. Old entries are
# deleted based on their last access time to maintain the configured size limit.
#
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2024-2025 Zerocracy
# License:: MIT
class Fbe::Middleware::SqliteStore
  attr_reader :path

  # Initialize the SQLite store.
  # @param path [String] Path to the SQLite database file
  # @param version [String] Version identifier for cache compatibility
  # @param loog [Loog] Logger instance (optional, defaults to Loog::NULL)
  # @param maxsize [Integer] Maximum database size in bytes (optional, defaults to 10MB)
  # @param maxvsize [Integer] Maximum size in bytes of a single value (optional, defaults to 10Kb)
  # @raise [ArgumentError] If path is nil/empty, directory doesn't exist, or version is nil/empty
  def initialize(path, version, loog: Loog::NULL, maxsize: '10Mb', maxvsize: '10Kb')
    raise ArgumentError, 'Database path cannot be nil or empty' if path.nil? || path.empty?
    dir = File.dirname(path)
    raise ArgumentError, "Directory #{dir} does not exist" unless File.directory?(dir)
    raise ArgumentError, 'Version cannot be nil or empty' if version.nil? || version.empty?
    @path = File.absolute_path(path)
    @version = version
    @loog = loog
    @maxsize = Filesize.from(maxsize.to_s).to_i
    @maxvsize = Filesize.from(maxvsize.to_s).to_i
  end

  # Read a value from the cache.
  # @param key [String] The cache key to read
  # @return [Object, nil] The cached value parsed from JSON, or nil if not found
  def read(key)
    value = perform do |t|
      t.execute('UPDATE cache SET touched_at = ?2 WHERE key = ?1;', [key, Time.now.utc.iso8601])
      t.execute('SELECT value FROM cache WHERE key = ? LIMIT 1;', [key])
    end.dig(0, 0)
    JSON.parse(value) if value
  end

  # Delete a key from the cache.
  # @param key [String] The cache key to delete
  # @return [nil]
  def delete(key)
    perform { _1.execute('DELETE FROM cache WHERE key = ?', [key]) }
    nil
  end

  # Write a value to the cache.
  # @param key [String] The cache key to write
  # @param value [Object] The value to cache (will be JSON encoded)
  # @return [nil]
  # @note Values larger than 10KB are not cached
  # @note Non-GET requests and URLs with query parameters are not cached
  def write(key, value)
    return if value.is_a?(Array) && value.any? do |vv|
      req = JSON.parse(vv[0])
      req['method'] != 'get'
    end
    value = JSON.dump(value)
    return if value.bytesize > @maxvsize
    perform do |t|
      t.execute(<<~SQL, [key, value, Time.now.utc.iso8601])
        INSERT INTO cache(key, value, touched_at) VALUES(?1, ?2, ?3)
        ON CONFLICT(key) DO UPDATE SET value = ?2, touched_at = ?3
      SQL
    end
    nil
  end

  # Clear all entries from the cache.
  # @return [void]
  def clear
    perform do |t|
      t.execute 'DELETE FROM cache;'
      t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
    end
    @db.execute 'VACUUM;'
  end

  # Get all entries from the cache.
  # @return [Array<Array>] Array of [key, value] pairs
  def all
    perform { _1.execute('SELECT key, value FROM cache') }
  end

  private

  def perform(&)
    @db ||=
      SQLite3::Database.new(@path).tap do |d|
        d.transaction do |t|
          t.execute 'CREATE TABLE IF NOT EXISTS cache(key TEXT UNIQUE NOT NULL, value TEXT);'
          t.execute 'CREATE INDEX IF NOT EXISTS cache_key_idx ON cache(key);'
          t.execute 'CREATE TABLE IF NOT EXISTS meta(key TEXT UNIQUE NOT NULL, value TEXT);'
          t.execute 'CREATE INDEX IF NOT EXISTS meta_key_idx ON meta(key);'
          t.execute "INSERT INTO meta(key, value) VALUES('version', ?) ON CONFLICT(key) DO NOTHING;", [@version]
        end
        if d.execute("SELECT 1 FROM pragma_table_info('cache') WHERE name = 'touched_at';").dig(0, 0) != 1
          d.transaction do |t|
            t.execute 'ALTER TABLE cache ADD COLUMN touched_at TEXT;'
            t.execute 'UPDATE cache set touched_at = ?;', [Time.now.utc.iso8601]
            t.execute 'ALTER TABLE cache RENAME TO cache_old;'
            t.execute <<~SQL
              CREATE TABLE IF NOT EXISTS cache(
                key TEXT UNIQUE NOT NULL, value TEXT, touched_at TEXT NOT NULL
              );
            SQL
            t.execute 'INSERT INTO cache SELECT * FROM cache_old;'
            t.execute 'DROP TABLE cache_old;'
            t.execute 'CREATE INDEX IF NOT EXISTS cache_touched_at_idx ON cache(touched_at);'
          end
          d.execute 'VACUUM;'
        end
        found = d.execute("SELECT value FROM meta WHERE key = 'version' LIMIT 1;").dig(0, 0)
        if found != @version
          @loog.info("Version mismatch in SQLite cache: stored '#{found}' != current '#{@version}', cleaning up")
          d.transaction do |t|
            t.execute 'DELETE FROM cache;'
            t.execute "UPDATE meta SET value = ? WHERE key = 'version';", [@version]
          end
          d.execute 'VACUUM;'
        end
        if File.size(@path) > @maxsize
          @loog.info(
            "SQLite cache file size (#{Filesize.from(File.size(@path).to_s).pretty} bytes) exceeds " \
            "#{Filesize.from(@maxsize.to_s).pretty}, cleaning up old entries"
          )
          deleted = 0
          while d.execute(<<~SQL).dig(0, 0) > @maxsize
            SELECT (page_count - freelist_count) * page_size AS size
            FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();
          SQL
            d.transaction do |t|
              t.execute <<~SQL
                DELETE FROM cache
                WHERE key IN (SELECT key FROM cache ORDER BY touched_at LIMIT 50)
              SQL
              deleted += t.changes
            end
          end
          d.execute 'VACUUM;'
          @loog.info(
            "Deleted #{deleted} old cache entries, " \
            "new file size: #{Filesize.from(File.size(@path).to_s).pretty} bytes"
          )
        end
        at_exit { @db&.close }
      end
    @db.transaction(&)
  end
end