BareGit

Add SQLite-backed probe history storage

Introduce DataSourceInterface and DataSourceSqlite for saving probe results
and querying latest results and history through libmw's SQLite wrapper.
Add CMake dependencies and tests for persistence, validation, and concurrency.
Author: MetroWind <chris.corsair@gmail.com>
Date: Fri Sep 18 13:54:39 2026 -0700
Commit: d87bdf327bbe2f83ac4fc02da73273f16273b77c

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index eb70bf9..63b515a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2,6 +2,41 @@ cmake_minimum_required(VERSION 3.24)
 
 project(status_tracker LANGUAGES CXX)
 
+include(FetchContent)
+set(LIBMW_BUILD_SQLITE ON CACHE BOOL "Build libmw SQLite support")
+FetchContent_Declare(libmw
+    GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+    GIT_TAG HEAD
+)
+FetchContent_MakeAvailable(libmw)
+find_package(SQLite3 REQUIRED)
+find_package(Threads REQUIRED)
+if(NOT TARGET SQLite3::SQLite3)
+    add_library(SQLite3::SQLite3 ALIAS SQLite::SQLite3)
+endif()
+
+add_library(status_tracker_data src/data_source_sqlite.cpp)
+target_compile_features(status_tracker_data PUBLIC cxx_std_23)
+set_target_properties(status_tracker_data PROPERTIES CXX_EXTENSIONS OFF)
+target_include_directories(status_tracker_data PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/src
+)
+target_include_directories(status_tracker_data SYSTEM PUBLIC
+    ${libmw_SOURCE_DIR}/includes
+)
+target_link_libraries(status_tracker_data
+    PRIVATE mw::sqlite
+    PUBLIC SQLite3::SQLite3 Threads::Threads
+)
+
 add_executable(status_tracker src/main.cpp)
+target_link_libraries(status_tracker PRIVATE status_tracker_data)
 target_compile_features(status_tracker PRIVATE cxx_std_23)
 set_target_properties(status_tracker PROPERTIES CXX_EXTENSIONS OFF)
+
+include(CTest)
+if(BUILD_TESTING)
+    add_executable(data_source_sqlite_test tests/data_source_sqlite_test.cpp)
+    target_link_libraries(data_source_sqlite_test PRIVATE status_tracker_data)
+    add_test(NAME DataSourceSqlite COMMAND data_source_sqlite_test)
+endif()
diff --git a/src/data_source_interface.h b/src/data_source_interface.h
new file mode 100644
index 0000000..1d06080
--- /dev/null
+++ b/src/data_source_interface.h
@@ -0,0 +1,55 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+/// Persisted probe outcomes; missing or stale data is derived separately.
+enum class ProbeStatus : int
+{
+    GOOD = 0,
+    BAD = 1,
+    OTHER = 2
+};
+
+/// One recorded probe result, independent of endpoint configuration.
+struct StatusRecord
+{
+    /// Unique service ID from the configuration.
+    std::string service_id;
+    /// UTC Unix timestamp in whole seconds.
+    std::int64_t timestamp;
+    /// Nonnegative probe duration in microseconds.
+    std::int64_t duration_microsecond;
+    /// Outcome of the probe.
+    ProbeStatus status;
+
+    /// Compare all persisted fields.
+    bool operator==(const StatusRecord&) const = default;
+};
+
+/// Thread-safe access to probe history; failures are returned as mw::E errors.
+/// Missing or stale data is interpreted as NA by the caller.
+class DataSourceInterface
+{
+public:
+    /// Release the data source through its interface.
+    virtual ~DataSourceInterface() = default;
+
+    /// Append a probe result to the service's history.
+    virtual mw::E<void> save(const StatusRecord& record) = 0;
+
+    /// Fetch the newest timestamp; ties use the most recently saved result.
+    /// Return an empty optional when the service has no history.
+    virtual mw::E<std::optional<StatusRecord>> latest(
+        const std::string& service_id) = 0;
+
+    /// Read [start, end) in timestamp order, then insertion order for ties.
+    /// Bounds are UTC Unix seconds. Reversed bounds return an error.
+    virtual mw::E<std::vector<StatusRecord>> history(
+        const std::string& service_id, std::int64_t start,
+        std::int64_t end) = 0;
+};
diff --git a/src/data_source_sqlite.cpp b/src/data_source_sqlite.cpp
new file mode 100644
index 0000000..821691f
--- /dev/null
+++ b/src/data_source_sqlite.cpp
@@ -0,0 +1,100 @@
+#include "data_source_sqlite.h"
+
+#include <utility>
+
+DataSourceSqlite::DataSourceSqlite(std::unique_ptr<mw::SQLite> database)
+    : database(std::move(database))
+{}
+
+DataSourceSqlite::~DataSourceSqlite() = default;
+
+mw::E<std::unique_ptr<DataSourceSqlite>> DataSourceSqlite::open(
+    const std::string& path)
+{
+    if(path.empty() || path.find('\0') != std::string::npos)
+    {
+        return std::unexpected(mw::runtimeError("Invalid database path"));
+    }
+    ASSIGN_OR_RETURN(auto database, mw::SQLite::connectFile(path));
+    DO_OR_RETURN(database->execute(R"(
+        CREATE TABLE IF NOT EXISTS status_history (
+            id INTEGER PRIMARY KEY,
+            service_id TEXT NOT NULL CHECK(length(service_id) > 0),
+            timestamp INTEGER NOT NULL,
+            duration_microsecond INTEGER NOT NULL
+                CHECK(duration_microsecond >= 0),
+            status INTEGER NOT NULL CHECK(status BETWEEN 0 AND 2)
+        )
+    )"));
+    DO_OR_RETURN(database->execute(R"(
+        CREATE INDEX IF NOT EXISTS status_history_service_time
+        ON status_history(service_id, timestamp, id)
+    )"));
+    return std::unique_ptr<DataSourceSqlite>(
+        new DataSourceSqlite(std::move(database)));
+}
+
+mw::E<void> DataSourceSqlite::save(const StatusRecord& record)
+{
+    const int status = static_cast<int>(record.status);
+    if(record.service_id.empty() ||
+       record.service_id.find('\0') != std::string::npos ||
+       record.duration_microsecond < 0 || status < 0 || status > 2)
+    {
+        return std::unexpected(mw::runtimeError("Invalid status record"));
+    }
+    ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
+        INSERT INTO status_history
+            (service_id, timestamp, duration_microsecond, status)
+        VALUES (?, ?, ?, ?)
+    )"));
+    DO_OR_RETURN(statement.bind(record.service_id, record.timestamp,
+                                record.duration_microsecond, status));
+    return database->execute(std::move(statement));
+}
+
+mw::E<std::optional<StatusRecord>> DataSourceSqlite::latest(
+    const std::string& service_id)
+{
+    ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
+        SELECT timestamp, duration_microsecond, status FROM status_history
+        WHERE service_id = ? ORDER BY timestamp DESC, id DESC LIMIT 1
+    )"));
+    DO_OR_RETURN(statement.bind(service_id));
+    ASSIGN_OR_RETURN(auto rows,
+        (database->eval<std::int64_t, std::int64_t, int>(
+            std::move(statement))));
+    if(rows.empty())
+    {
+        return std::optional<StatusRecord>{};
+    }
+    const auto& [timestamp, duration, status] = rows.front();
+    return StatusRecord{service_id, timestamp, duration,
+                        static_cast<ProbeStatus>(status)};
+}
+
+mw::E<std::vector<StatusRecord>> DataSourceSqlite::history(
+    const std::string& service_id, std::int64_t start, std::int64_t end)
+{
+    if(start > end)
+    {
+        return std::unexpected(mw::runtimeError("Reversed history bounds"));
+    }
+    ASSIGN_OR_RETURN(auto statement, database->statementFromStr(R"(
+        SELECT timestamp, duration_microsecond, status FROM status_history
+        WHERE service_id = ? AND timestamp >= ? AND timestamp < ?
+        ORDER BY timestamp, id
+    )"));
+    DO_OR_RETURN(statement.bind(service_id, start, end));
+    ASSIGN_OR_RETURN(auto rows,
+        (database->eval<std::int64_t, std::int64_t, int>(
+            std::move(statement))));
+    std::vector<StatusRecord> records;
+    records.reserve(rows.size());
+    for(const auto& [timestamp, duration, status] : rows)
+    {
+        records.push_back({service_id, timestamp, duration,
+                           static_cast<ProbeStatus>(status)});
+    }
+    return records;
+}
diff --git a/src/data_source_sqlite.h b/src/data_source_sqlite.h
new file mode 100644
index 0000000..8fe5bd6
--- /dev/null
+++ b/src/data_source_sqlite.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <memory>
+
+#include <mw/database.hpp>
+
+#include "data_source_interface.h"
+
+/// SQLite-backed history; requires SQLite's serialized threading mode.
+class DataSourceSqlite final : public DataSourceInterface
+{
+public:
+    /// Open or create a database and schema; use :memory: for transient data.
+    static mw::E<std::unique_ptr<DataSourceSqlite>> open(
+        const std::string& path);
+
+    /// Close the connection and release its resources.
+    ~DataSourceSqlite() override;
+
+    /// Append a validated probe result using bound parameters.
+    mw::E<void> save(const StatusRecord& record) override;
+
+    /// Return the latest result, or an empty optional for an unknown service.
+    mw::E<std::optional<StatusRecord>> latest(
+        const std::string& service_id) override;
+
+    /// Return history in [start, end), ordered by timestamp and insertion.
+    mw::E<std::vector<StatusRecord>> history(
+        const std::string& service_id, std::int64_t start,
+        std::int64_t end) override;
+
+private:
+    explicit DataSourceSqlite(std::unique_ptr<mw::SQLite> database);
+
+    std::unique_ptr<mw::SQLite> database;
+};
diff --git a/tests/data_source_sqlite_test.cpp b/tests/data_source_sqlite_test.cpp
new file mode 100644
index 0000000..3a748f1
--- /dev/null
+++ b/tests/data_source_sqlite_test.cpp
@@ -0,0 +1,143 @@
+#include "data_source_sqlite.h"
+
+#include <atomic>
+#include <cstdlib>
+#include <filesystem>
+#include <iostream>
+#include <stdexcept>
+#include <thread>
+#include <unistd.h>
+
+namespace
+{
+
+void require(bool condition, const char* message)
+{
+    if(!condition)
+    {
+        throw std::runtime_error(message);
+    }
+}
+
+void History()
+{
+    auto opened = DataSourceSqlite::open(":memory:");
+    require(opened.has_value(), "Open memory database");
+    DataSourceInterface& source = **opened;
+    require(!source.latest("missing").value(), "Missing latest result");
+    require(source.history("missing", 0, 100).value().empty(),
+            "Missing history");
+    const std::string service_id = "blog'; DROP TABLE status_history; --";
+    const StatusRecord newer{service_id, 2200000000, 5000000000,
+                             ProbeStatus::GOOD};
+    const StatusRecord older{service_id, 2199999999, 0, ProbeStatus::BAD};
+    const StatusRecord tied{service_id, 2200000000, 42, ProbeStatus::OTHER};
+    require(source.save(newer).has_value(), "Save 64-bit fields");
+    require(source.save(older).has_value(), "Save out of order");
+    require(source.save(tied).has_value(), "Retain same-second results");
+    require(source.save({"other", 2200000001, 1, ProbeStatus::GOOD})
+                .has_value(), "Save separate service");
+    require(source.latest(service_id).value().value() == tied,
+            "Latest uses timestamp and insertion order");
+    const auto rows = source.history(service_id, 2199999999, 2200000001)
+                          .value();
+    require(rows == std::vector<StatusRecord>{older, newer, tied},
+            "Ordered history and bound service ID");
+    require(source.history(service_id, 2199999999, 2200000000).value() ==
+                std::vector<StatusRecord>{older}, "Exclusive upper bound");
+    require(source.history(service_id, 0, 0).value().empty(), "Empty range");
+    require(!source.history(service_id, 1, 0), "Reject reversed bounds");
+    require(!source.save({"", 0, 0, ProbeStatus::GOOD}), "Reject empty ID");
+    require(!source.save({"x", 0, -1, ProbeStatus::GOOD}),
+            "Reject negative duration");
+    require(!source.save({"x", 0, 0, static_cast<ProbeStatus>(3)}),
+            "Reject former NA value");
+    require(!source.save({"x", 0, 0, static_cast<ProbeStatus>(99)}),
+            "Reject invalid status");
+    require(!DataSourceSqlite::open(""), "Reject empty path");
+}
+
+void writeResults(DataSourceInterface& source, std::atomic<bool>& success,
+                  int worker)
+{
+    for(int i = 0; i < 100; ++i)
+    {
+        if(!source.save({"service", worker * 100 + i, 1,
+                         ProbeStatus::GOOD}) || !source.latest("service"))
+        {
+            success = false;
+        }
+    }
+}
+
+void ConcurrentAccess()
+{
+    auto source = DataSourceSqlite::open(":memory:").value();
+    std::atomic<bool> success{true};
+    std::vector<std::thread> workers;
+    for(int worker = 0; worker < 8; ++worker)
+    {
+        workers.emplace_back(writeResults, std::ref(*source),
+                             std::ref(success), worker);
+    }
+    for(auto& worker : workers)
+    {
+        worker.join();
+    }
+    require(success, "Concurrent reads and writes");
+    require(source->history("service", 0, 800).value().size() == 800,
+            "Concurrent writes retain all results");
+}
+
+void Persistence()
+{
+    std::string pattern =
+        (std::filesystem::temp_directory_path() / "status_tracker_XXXXXX")
+            .string();
+    const int descriptor = mkstemp(pattern.data());
+    require(descriptor >= 0, "Create temporary database file");
+    close(descriptor);
+    const StatusRecord record{"persistent", 123, 456, ProbeStatus::GOOD};
+    try
+    {
+        {
+            auto source = DataSourceSqlite::open(pattern).value();
+            require(source->save(record).has_value(), "Save to disk");
+            auto second = DataSourceSqlite::open(pattern).value();
+            require(second->latest("persistent").value().value() == record,
+                    "Read committed data from another connection");
+        }
+        {
+            auto source = DataSourceSqlite::open(pattern).value();
+            require(source->latest("persistent").value().value() == record,
+                    "Reopen existing schema and history");
+        }
+    }
+    catch(...)
+    {
+        std::filesystem::remove(pattern);
+        throw;
+    }
+    std::filesystem::remove(pattern);
+    require(!DataSourceSqlite::open(pattern + "/missing.sqlite"),
+            "Report database open errors");
+}
+
+}
+
+/// Run storage integration tests without relying on debug-only assertions.
+int main()
+{
+    try
+    {
+        History();
+        ConcurrentAccess();
+        Persistence();
+    }
+    catch(const std::exception& error)
+    {
+        std::cerr << error.what() << '\n';
+        return 1;
+    }
+    return 0;
+}