[M2] add SQLite overlay + add_manual

This commit is contained in:
2026-05-08 12:14:24 +00:00
parent d5715428ea
commit cafa403a58
8 changed files with 524 additions and 49 deletions

View File

@@ -71,22 +71,22 @@ auto load_curated(const std::filesystem::path& path)
return out;
}
} // namespace
auto Database::open() -> util::Result<Database> {
Database db;
auto curated = load_curated(CARGOXX_LINKDB_DEFAULT_PATH);
if (!curated) {
return std::unexpected(curated.error());
auto default_overlay_path() -> std::filesystem::path {
namespace fs = std::filesystem;
if (auto* xdg = std::getenv("XDG_CACHE_HOME"); xdg && *xdg) {
return fs::path{xdg} / "cargoxx" / "linkdb.sqlite";
}
db.curated_ = std::move(*curated);
return db;
if (auto* home = std::getenv("HOME"); home && *home) {
return fs::path{home} / ".cache" / "cargoxx" / "linkdb.sqlite";
}
return fs::current_path() / ".cargoxx-linkdb.sqlite";
}
auto Database::resolve(const std::string& package, const std::string& version,
const std::vector<std::string>& components) -> util::Result<Recipe> {
auto it = curated_.find(package);
if (it == curated_.end() || it->second.empty()) {
auto resolve_curated(const std::map<std::string, std::vector<detail::CuratedRecipe>>& curated,
const std::string& package, const std::string& version,
const std::vector<std::string>& components) -> util::Result<Recipe> {
auto it = curated.find(package);
if (it == curated.end() || it->second.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::LinkdbUnknownPackage,
std::format("package '{}' has no known CMake link recipe", package),
@@ -126,22 +126,69 @@ auto Database::resolve(const std::string& package, const std::string& version,
});
}
Recipe out{
return Recipe{
.nixpkgs_attr = match->nixpkgs_attr,
.find_package = substitute_components(match->find_package, components),
.targets = expand_targets(match->targets, components),
.source = "curated",
};
return out;
}
auto Database::add_manual(const std::string&, const std::string&, const Recipe&)
-> util::Result<void> {
return std::unexpected(util::Error{
util::ErrorCode::NotImplemented,
"manual link recipes are not implemented in this milestone",
"", std::nullopt, std::nullopt,
});
} // namespace
auto Database::open(std::optional<std::filesystem::path> overlay_path) -> util::Result<Database> {
Database db;
auto curated = load_curated(CARGOXX_LINKDB_DEFAULT_PATH);
if (!curated) {
return std::unexpected(curated.error());
}
db.curated_ = std::move(*curated);
auto path = overlay_path.value_or(default_overlay_path());
auto handle = detail::overlay_open(path);
if (!handle) {
return std::unexpected(handle.error());
}
db.overlay_ = std::move(*handle);
return db;
}
auto Database::resolve(const std::string& package, const std::string& version,
const std::vector<std::string>& components) -> util::Result<Recipe> {
if (overlay_) {
auto rows = detail::overlay_query(*overlay_, package);
if (!rows) {
return std::unexpected(rows.error());
}
const auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
for (const auto& row : *rows) {
if (!detail::overlay_is_fresh(row, now)) {
continue;
}
if (!util::satisfies(version, row.version_range)) {
continue;
}
if (!components.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::LinkdbComponentNotSupported,
std::format("overlay recipe for '{}' does not support components", package),
"", std::nullopt, std::nullopt,
});
}
return Recipe{
.nixpkgs_attr = row.nixpkgs_attr,
.find_package = row.find_package,
.targets = row.targets,
.source = row.source,
};
}
}
return resolve_curated(curated_, package, version, components);
}
} // namespace cargoxx::linkdb

View File

@@ -1,3 +1,7 @@
module;
#include <sqlite3.h>
export module cargoxx.linkdb;
import std;
@@ -26,13 +30,60 @@ struct CuratedRecipe {
bool components_supported = false;
};
struct OverlayRow {
std::string version_range;
std::string nixpkgs_attr;
std::string find_package;
std::vector<std::string> targets;
std::string source;
std::int64_t verified_at = 0;
};
// RAII wrapper for an open sqlite3 connection used by the overlay database.
// Move-only would be redundant — the holder (Database::overlay_) is a
// std::unique_ptr<OverlayState>, so we delete copy and move outright.
class OverlayState {
public:
OverlayState() = default;
explicit OverlayState(sqlite3* handle) noexcept : db_(handle) {}
~OverlayState() {
if (db_) {
sqlite3_close(db_);
}
}
OverlayState(const OverlayState&) = delete;
OverlayState& operator=(const OverlayState&) = delete;
OverlayState(OverlayState&&) = delete;
OverlayState& operator=(OverlayState&&) = delete;
[[nodiscard]] auto handle() const noexcept -> sqlite3* { return db_; }
private:
sqlite3* db_ = nullptr;
};
auto overlay_open(const std::filesystem::path& path)
-> cargoxx::util::Result<std::unique_ptr<OverlayState>>;
auto overlay_insert_manual(OverlayState& state, const std::string& package,
const std::string& version_range,
const cargoxx::linkdb::Recipe& r)
-> cargoxx::util::Result<void>;
auto overlay_query(OverlayState& state, const std::string& package)
-> cargoxx::util::Result<std::vector<OverlayRow>>;
auto overlay_is_fresh(const OverlayRow& row, std::int64_t now_epoch_seconds) -> bool;
} // namespace cargoxx::linkdb::detail
export namespace cargoxx::linkdb {
class Database {
public:
static auto open() -> util::Result<Database>;
static auto open(std::optional<std::filesystem::path> overlay_path = std::nullopt)
-> util::Result<Database>;
auto resolve(const std::string& package, const std::string& version,
const std::vector<std::string>& components = {})
@@ -44,6 +95,7 @@ class Database {
private:
Database() = default;
std::map<std::string, std::vector<detail::CuratedRecipe>> curated_;
std::unique_ptr<detail::OverlayState> overlay_;
};
// Pure helpers exported for unit testing.

195
src/linkdb/overlay.cpp Normal file
View File

@@ -0,0 +1,195 @@
module;
#include <json.hpp>
#include <sqlite3.h>
module cargoxx.linkdb;
import std;
import cargoxx.util;
namespace cargoxx::linkdb {
namespace {
constexpr const char* SCHEMA_SQL = R"(
CREATE TABLE IF NOT EXISTS recipes (
package TEXT NOT NULL,
version_range TEXT NOT NULL,
nixpkgs_attr TEXT NOT NULL,
find_package TEXT NOT NULL,
targets TEXT NOT NULL,
components TEXT,
source TEXT NOT NULL,
verified_at INTEGER NOT NULL,
PRIMARY KEY (package, version_range, source)
);
CREATE TABLE IF NOT EXISTS resolution_failures (
package TEXT NOT NULL,
version TEXT NOT NULL,
last_attempt INTEGER NOT NULL,
error TEXT NOT NULL,
PRIMARY KEY (package, version)
);
)";
constexpr std::int64_t THIRTY_DAYS_SECONDS = 30LL * 24 * 60 * 60;
auto sqlite_error(sqlite3* db, std::string_view ctx) -> util::Error {
return util::Error{
util::ErrorCode::LinkdbCorrupt,
std::format("{}: {}", ctx, sqlite3_errmsg(db)),
"", std::nullopt, std::nullopt,
};
}
auto column_text(sqlite3_stmt* stmt, int idx) -> std::string {
const auto* p = sqlite3_column_text(stmt, idx);
if (!p) {
return {};
}
return std::string{reinterpret_cast<const char*>(p)};
}
} // namespace
namespace detail {
auto overlay_open(const std::filesystem::path& path)
-> util::Result<std::unique_ptr<OverlayState>> {
std::error_code ec;
if (path.has_parent_path()) {
std::filesystem::create_directories(path.parent_path(), ec);
// ec is non-fatal; sqlite3_open will report a clearer error if it matters
}
sqlite3* db = nullptr;
auto rc = sqlite3_open(path.string().c_str(), &db);
if (rc != SQLITE_OK) {
std::string msg = std::format("cannot open overlay '{}': {}", path.string(),
db ? sqlite3_errmsg(db) : sqlite3_errstr(rc));
if (db) {
sqlite3_close(db);
}
return std::unexpected(util::Error{
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt,
});
}
auto state = std::make_unique<OverlayState>(db);
char* errmsg = nullptr;
rc = sqlite3_exec(state->handle(), SCHEMA_SQL, nullptr, nullptr, &errmsg);
if (rc != SQLITE_OK) {
std::string msg =
std::format("cannot create overlay schema: {}", errmsg ? errmsg : "?");
sqlite3_free(errmsg);
return std::unexpected(util::Error{
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt,
});
}
return state;
}
auto overlay_insert_manual(OverlayState& state, const std::string& package,
const std::string& version_range, const Recipe& r)
-> util::Result<void> {
constexpr const char* SQL =
"INSERT OR REPLACE INTO recipes "
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
" verified_at) "
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?)";
sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, SQL, -1, &stmt, nullptr) != SQLITE_OK) {
return std::unexpected(sqlite_error(db, "prepare insert"));
}
auto targets_str = nlohmann::json(r.targets).dump();
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, version_range.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, r.nixpkgs_attr.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, r.find_package.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 6, now);
auto rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
return std::unexpected(sqlite_error(db, "step insert"));
}
return {};
}
auto overlay_query(OverlayState& state, const std::string& package)
-> util::Result<std::vector<OverlayRow>> {
constexpr const char* SQL =
"SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at "
"FROM recipes WHERE package = ?";
sqlite3* db = state.handle();
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, SQL, -1, &stmt, nullptr) != SQLITE_OK) {
return std::unexpected(sqlite_error(db, "prepare select"));
}
sqlite3_bind_text(stmt, 1, package.c_str(), -1, SQLITE_TRANSIENT);
std::vector<OverlayRow> out;
int rc;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
OverlayRow row;
row.version_range = column_text(stmt, 0);
row.nixpkgs_attr = column_text(stmt, 1);
row.find_package = column_text(stmt, 2);
auto targets_text = column_text(stmt, 3);
try {
row.targets =
nlohmann::json::parse(targets_text).get<std::vector<std::string>>();
} catch (const nlohmann::json::exception&) {
sqlite3_finalize(stmt);
return std::unexpected(util::Error{
util::ErrorCode::LinkdbCorrupt,
std::format("overlay row for '{}' has malformed targets JSON", package),
"", std::nullopt, std::nullopt,
});
}
row.source = column_text(stmt, 4);
row.verified_at = sqlite3_column_int64(stmt, 5);
out.push_back(std::move(row));
}
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
return std::unexpected(sqlite_error(db, "step select"));
}
return out;
}
auto overlay_is_fresh(const OverlayRow& row, std::int64_t now) -> bool {
if (row.source == "manual" || row.source == "curated") {
return true;
}
return (now - row.verified_at) < THIRTY_DAYS_SECONDS;
}
} // namespace detail
auto Database::add_manual(const std::string& package, const std::string& version_range,
const Recipe& r) -> util::Result<void> {
if (!overlay_) {
return std::unexpected(util::Error{
util::ErrorCode::Internal,
"no overlay database is open",
"", std::nullopt, std::nullopt,
});
}
return detail::overlay_insert_manual(*overlay_, package, version_range, r);
}
} // namespace cargoxx::linkdb