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;
+}