BareGit

Bootstrap the application and persistence boundary

- Add the C++23 CMake build with floating third-party dependencies and
  ImageMagick integration.
- Define card and game records plus data source and transaction interfaces.
- Add GoogleMock data sources and a smoke test for their implementations.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 12:20:49 2026 -0700
Commit: 09c51d19f7669d9796b4484a2f751dc736cff1d5

Changes

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ed22deb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/build/
+/build-*/
+/var/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..65c5a50
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,79 @@
+cmake_minimum_required(VERSION 3.26)
+
+project(
+    card_collection
+    VERSION 0.1.0
+    LANGUAGES CXX
+)
+
+option(
+    CARD_COLLECTION_BUILD_TESTS
+    "Build the Card Collection test suite"
+    ON
+)
+
+include(cmake/dependencies.cmake)
+
+add_executable(
+    card_collection
+    src/data.cpp
+    src/main.cpp
+)
+
+target_compile_features(card_collection PRIVATE cxx_std_23)
+set_target_properties(
+    card_collection
+    PROPERTIES
+        CXX_EXTENSIONS OFF
+)
+
+if(MSVC)
+    target_compile_options(card_collection PRIVATE /W4 /permissive-)
+else()
+    target_compile_options(
+        card_collection
+        PRIVATE
+            -Wall
+            -Wextra
+            -Wpedantic
+    )
+endif()
+
+target_link_libraries(
+    card_collection
+    PRIVATE
+        ImageMagick::Magick++
+        MacroDown::MacroDown
+        mw::http-server
+        mw::mw
+        mw::sqlite
+        mw::url
+        pantor::inja
+        spdlog::spdlog
+        tomlplusplus::tomlplusplus
+)
+
+if(CARD_COLLECTION_BUILD_TESTS)
+    include(CTest)
+    enable_testing()
+
+    add_executable(data_mock_test tests/data_mock_test.cpp)
+    target_compile_features(data_mock_test PRIVATE cxx_std_23)
+    set_target_properties(data_mock_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        data_mock_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+            tests
+    )
+    target_link_libraries(
+        data_mock_test
+        PRIVATE
+            GTest::gmock_main
+            mw::mw
+    )
+
+    include(GoogleTest)
+    gtest_discover_tests(data_mock_test)
+endif()
diff --git a/cmake/dependencies.cmake b/cmake/dependencies.cmake
new file mode 100644
index 0000000..9c4d9ed
--- /dev/null
+++ b/cmake/dependencies.cmake
@@ -0,0 +1,90 @@
+include(FetchContent)
+
+# Declare transitive content first because FetchContent uses the first
+# declaration it encounters. This keeps every dependency on a floating branch.
+FetchContent_Declare(
+    httplib
+    GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
+    GIT_TAG master
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    json
+    GIT_REPOSITORY https://github.com/nlohmann/json.git
+    GIT_TAG develop
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    uni-algo
+    GIT_REPOSITORY https://github.com/uni-algo/uni-algo.git
+    GIT_TAG main
+    GIT_SHALLOW TRUE
+)
+
+FetchContent_Declare(
+    libmw
+    GIT_REPOSITORY https://github.com/MetroWind/libmw.git
+    GIT_TAG master
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    macrodown
+    GIT_REPOSITORY https://git.xeno.darksair.org/macrodown.git
+    GIT_TAG master
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    tomlplusplus
+    GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
+    GIT_TAG master
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    inja
+    GIT_REPOSITORY https://github.com/pantor/inja.git
+    GIT_TAG main
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    spdlog
+    GIT_REPOSITORY https://github.com/gabime/spdlog.git
+    GIT_TAG v1.x
+    GIT_SHALLOW TRUE
+)
+FetchContent_Declare(
+    googletest
+    GIT_REPOSITORY https://github.com/google/googletest.git
+    GIT_TAG main
+    GIT_SHALLOW TRUE
+)
+
+set(LIBMW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_URL ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_SQLITE ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_HTTP_SERVER ON CACHE BOOL "" FORCE)
+set(LIBMW_BUILD_CRYPTO OFF CACHE BOOL "" FORCE)
+
+set(INJA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(INJA_EXPORT OFF CACHE BOOL "" FORCE)
+set(INJA_INSTALL OFF CACHE BOOL "" FORCE)
+set(INJA_USE_EMBEDDED_JSON OFF CACHE BOOL "" FORCE)
+
+set(SPDLOG_BUILD_EXAMPLE OFF CACHE BOOL "" FORCE)
+set(SPDLOG_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(SPDLOG_INSTALL OFF CACHE BOOL "" FORCE)
+set(SPDLOG_USE_STD_FORMAT ON CACHE BOOL "" FORCE)
+
+FetchContent_MakeAvailable(
+    spdlog
+    libmw
+    macrodown
+    tomlplusplus
+    inja
+)
+
+if(CARD_COLLECTION_BUILD_TESTS)
+    set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+    FetchContent_MakeAvailable(googletest)
+endif()
+
+find_package(ImageMagick 7 REQUIRED COMPONENTS Magick++)
diff --git a/src/card.h b/src/card.h
new file mode 100644
index 0000000..ab2fc31
--- /dev/null
+++ b/src/card.h
@@ -0,0 +1,62 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+/// Persisted components from which a public card ID is derived.
+struct CardIdentity
+{
+    /// Lowercase compiled game name, or null for a loose card.
+    std::optional<std::string> game_short_name;
+
+    /// Sequential game number or random loose-card number.
+    std::uint64_t card_number;
+};
+
+/// Common persisted and asset metadata for one card.
+struct Card
+{
+    /// Internal SQLite primary key, or zero before insertion.
+    std::int64_t id;
+
+    /// Persisted components of the card's public identity.
+    CardIdentity identity;
+
+    /// Human-readable card name.
+    std::string name;
+
+    /// Optional short Markdown description.
+    std::optional<std::string> short_description;
+
+    /// Optional long Markdown description.
+    std::optional<std::string> long_description;
+
+    /// Nonnegative rarity value.
+    std::int64_t rarity;
+
+    /// Stored front-art filename extension.
+    std::string front_extension;
+
+    /// Stored foil-control filename extension, when present.
+    std::optional<std::string> foil_extension;
+
+    /// Stored thumbnail filename extension.
+    std::string thumbnail_extension;
+
+    /// Optimistic-concurrency and asset-cache revision.
+    std::int64_t revision;
+};
+
+/// Logical card image used consistently by validation, storage, and URLs.
+enum class CardAssetType
+{
+    /// Full-size front artwork.
+    FRONT_ART,
+
+    /// Optional alpha-bearing foil-control texture.
+    FOIL_CONTROL,
+
+    /// Static card-index thumbnail.
+    THUMBNAIL
+};
diff --git a/src/data.cpp b/src/data.cpp
new file mode 100644
index 0000000..5a343c0
--- /dev/null
+++ b/src/data.cpp
@@ -0,0 +1,67 @@
+#include "data.h"
+
+#include <string>
+#include <utility>
+
+namespace
+{
+
+mw::Error unsupportedVersionError(std::int64_t version)
+{
+    return mw::runtimeError(
+        "Database schema version " + std::to_string(version) +
+        " is newer than supported version " +
+        std::to_string(DB_SCHEMA_VERSION) + ".");
+}
+
+mw::Error migrationVersionError(
+    std::int64_t expected_version,
+    std::int64_t actual_version)
+{
+    return mw::runtimeError(
+        "Database migration should have produced schema version " +
+        std::to_string(expected_version) + ", but produced version " +
+        std::to_string(actual_version) + ".");
+}
+
+} // namespace
+
+mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
+{
+    auto version_result = getSchemaVersion();
+    if(!version_result)
+    {
+        return std::unexpected(std::move(version_result.error()));
+    }
+
+    std::int64_t version = *version_result;
+    if(version > DB_SCHEMA_VERSION)
+    {
+        return std::unexpected(unsupportedVersionError(version));
+    }
+
+    while(version < DB_SCHEMA_VERSION)
+    {
+        const std::int64_t expected_version = version + 1;
+        mw::E<void> migration_result = migrateSchema0To1(games);
+        if(!migration_result)
+        {
+            return std::unexpected(std::move(migration_result.error()));
+        }
+
+        version_result = getSchemaVersion();
+        if(!version_result)
+        {
+            return std::unexpected(std::move(version_result.error()));
+        }
+
+        version = *version_result;
+        if(version != expected_version)
+        {
+            return std::unexpected(
+                migrationVersionError(expected_version, version));
+        }
+    }
+
+    return {};
+}
diff --git a/src/data.h b/src/data.h
new file mode 100644
index 0000000..8bad276
--- /dev/null
+++ b/src/data.h
@@ -0,0 +1,129 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "card.h"
+#include "game.h"
+
+/// Polymorphic validated metadata owned by a compiled game.
+class GameCardMetadata;
+
+/// Compiled behavior and metadata schema for one game.
+class GameDefinition;
+
+/// Immutable collection of compiled game definitions.
+class GameRegistry;
+
+/// Latest schema version implemented by this binary.
+inline constexpr std::int64_t DB_SCHEMA_VERSION = 1;
+
+/// Storage transaction used for an atomic group of persistence operations.
+class DataSourceTransactionInterface
+{
+public:
+    /// Destroy the transaction, rolling it back when it remains uncommitted.
+    virtual ~DataSourceTransactionInterface() = default;
+
+    /// Allocate and persist the next never-reused number for one game.
+    virtual mw::E<std::uint64_t>
+    allocateGameNumber(const std::string& game_short_name) = 0;
+
+    /// Ensure a registered game has a persistent sequence row.
+    virtual mw::E<void>
+    ensureGameSequence(const std::string& game_short_name) = 0;
+
+    /// Return whether a loose-card number already exists.
+    virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
+
+    /// Re-read a card while the transaction lock is held.
+    virtual mw::E<std::optional<Card>>
+    getCardForUpdate(std::int64_t card_id) = 0;
+
+    /// Insert common, game-specific, and series-membership card rows.
+    virtual mw::E<std::int64_t> insertCard(
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) = 0;
+
+    /// Replace a card's common, game-specific, and membership rows.
+    virtual mw::E<void> updateCard(
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids) = 0;
+
+    /// Delete a card and its dependent database rows.
+    virtual mw::E<void> deleteCard(std::int64_t card_id) = 0;
+
+    /// Insert a series and return its internal ID.
+    virtual mw::E<std::int64_t> insertSeries(const Series& series) = 0;
+
+    /// Replace a series name and description without changing its game.
+    virtual mw::E<void> updateSeries(const Series& series) = 0;
+
+    /// Delete a series and its membership rows.
+    virtual mw::E<void> deleteSeries(std::int64_t series_id) = 0;
+
+    /// Commit the transaction and release its lock.
+    virtual mw::E<void> commit() = 0;
+};
+
+/// Common persistence API used by the application and services.
+class DataSourceInterface
+{
+public:
+    /// Destroy the data source after all transactions have ended.
+    virtual ~DataSourceInterface() = default;
+
+    /// Return the stored schema version.
+    virtual mw::E<std::int64_t> getSchemaVersion() const = 0;
+
+    /// Apply every required migration in order through the current version.
+    mw::E<void> migrateToLatest(const GameRegistry& games);
+
+    /// Create schema version 1 from an empty version-0 database.
+    virtual mw::E<void>
+    migrateSchema0To1(const GameRegistry& games) = 0;
+
+    /// Start an immediate transaction with exclusive mutation ownership.
+    virtual mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+    beginTransaction() = 0;
+
+    /// Return all cards for the unpaginated index.
+    virtual mw::E<std::vector<Card>> getCards() const = 0;
+
+    /// Return a card by its parsed identity.
+    virtual mw::E<std::optional<Card>>
+    getCard(const CardIdentity& identity) const = 0;
+
+    /// Return a card's game-owned display fields.
+    virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
+        const GameDefinition& game,
+        std::int64_t card_id) const = 0;
+
+    /// Return all series, ordered by game and name.
+    virtual mw::E<std::vector<Series>> getSeries() const = 0;
+
+    /// Return one series by internal ID.
+    virtual mw::E<std::optional<Series>>
+    getSeries(std::int64_t series_id) const = 0;
+
+    /// Return the series memberships for one card.
+    virtual mw::E<std::vector<std::int64_t>>
+    getCardSeries(std::int64_t card_id) const = 0;
+
+    /// Return every persisted game name used for startup reconciliation.
+    virtual mw::E<std::vector<std::string>>
+    getPersistedGameNames() const = 0;
+
+protected:
+    /// Set the schema version inside a concrete migration transaction.
+    virtual mw::E<void> setSchemaVersion(std::int64_t version) = 0;
+};
diff --git a/src/game.h b/src/game.h
new file mode 100644
index 0000000..73362ed
--- /dev/null
+++ b/src/game.h
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+/// One game-specific label and value shown on a card page.
+struct DisplayField
+{
+    /// Human-readable field label.
+    std::string label;
+
+    /// Human-readable field value.
+    std::string value;
+};
+
+/// Dynamic series metadata belonging to one compiled game.
+struct Series
+{
+    /// Internal SQLite primary key, or zero before insertion.
+    std::int64_t id;
+
+    /// Lowercase short name of the owning compiled game.
+    std::string game_short_name;
+
+    /// Series name, unique within its game.
+    std::string name;
+
+    /// Markdown series description.
+    std::string description;
+};
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..888fc25
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,5 @@
+/// Start the Card Collection server.
+int main()
+{
+    return 0;
+}
diff --git a/tests/data_mock.h b/tests/data_mock.h
new file mode 100644
index 0000000..3afc0fc
--- /dev/null
+++ b/tests/data_mock.h
@@ -0,0 +1,177 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gmock/gmock.h>
+
+#include "data.h"
+
+/// GoogleMock transaction used by service and handler tests.
+class DataSourceTransactionMock : public DataSourceTransactionInterface
+{
+public:
+    /// Mock allocation of a never-reused game card number.
+    MOCK_METHOD(
+        (mw::E<std::uint64_t>),
+        allocateGameNumber,
+        (const std::string& game_short_name),
+        (override));
+
+    /// Mock creation of a game's persistent sequence row.
+    MOCK_METHOD(
+        (mw::E<void>),
+        ensureGameSequence,
+        (const std::string& game_short_name),
+        (override));
+
+    /// Mock the loose-card number collision query.
+    MOCK_METHOD(
+        (mw::E<bool>),
+        looseNumberExists,
+        (std::uint32_t number),
+        (override));
+
+    /// Mock a card read performed under the transaction lock.
+    MOCK_METHOD(
+        (mw::E<std::optional<Card>>),
+        getCardForUpdate,
+        (std::int64_t card_id),
+        (override));
+
+    /// Mock insertion of a card and its dependent rows.
+    MOCK_METHOD(
+        (mw::E<std::int64_t>),
+        insertCard,
+        (const Card& card,
+         const GameDefinition* game,
+         const GameCardMetadata* metadata,
+         const std::vector<std::int64_t>& series_ids),
+        (override));
+
+    /// Mock replacement of a card and its dependent rows.
+    MOCK_METHOD(
+        (mw::E<void>),
+        updateCard,
+        (const Card& card,
+         const GameDefinition* game,
+         const GameCardMetadata* metadata,
+         const std::vector<std::int64_t>& series_ids),
+        (override));
+
+    /// Mock deletion of a card and its dependent rows.
+    MOCK_METHOD(
+        (mw::E<void>),
+        deleteCard,
+        (std::int64_t card_id),
+        (override));
+
+    /// Mock insertion of a series.
+    MOCK_METHOD(
+        (mw::E<std::int64_t>),
+        insertSeries,
+        (const Series& series),
+        (override));
+
+    /// Mock replacement of a series.
+    MOCK_METHOD(
+        (mw::E<void>),
+        updateSeries,
+        (const Series& series),
+        (override));
+
+    /// Mock deletion of a series and its memberships.
+    MOCK_METHOD(
+        (mw::E<void>),
+        deleteSeries,
+        (std::int64_t series_id),
+        (override));
+
+    /// Mock transaction commit.
+    MOCK_METHOD((mw::E<void>), commit, (), (override));
+};
+
+/// GoogleMock data source used by service and handler tests.
+class DataSourceMock : public DataSourceInterface
+{
+public:
+    /// Mock retrieval of the stored schema version.
+    MOCK_METHOD(
+        (mw::E<std::int64_t>),
+        getSchemaVersion,
+        (),
+        (const, override));
+
+    /// Mock creation of schema version 1.
+    MOCK_METHOD(
+        (mw::E<void>),
+        migrateSchema0To1,
+        (const GameRegistry& games),
+        (override));
+
+    /// Mock creation of a mutation transaction.
+    MOCK_METHOD(
+        (mw::E<std::unique_ptr<DataSourceTransactionInterface>>),
+        beginTransaction,
+        (),
+        (override));
+
+    /// Mock retrieval of all cards.
+    MOCK_METHOD(
+        (mw::E<std::vector<Card>>),
+        getCards,
+        (),
+        (const, override));
+
+    /// Mock retrieval of a card by identity.
+    MOCK_METHOD(
+        (mw::E<std::optional<Card>>),
+        getCard,
+        (const CardIdentity& identity),
+        (const, override));
+
+    /// Mock retrieval of game-specific card display fields.
+    MOCK_METHOD(
+        (mw::E<std::vector<DisplayField>>),
+        getGameDisplayFields,
+        (const GameDefinition& game, std::int64_t card_id),
+        (const, override));
+
+    /// Mock retrieval of all series.
+    MOCK_METHOD(
+        (mw::E<std::vector<Series>>),
+        getSeries,
+        (),
+        (const, override));
+
+    /// Mock retrieval of one series by internal ID.
+    MOCK_METHOD(
+        (mw::E<std::optional<Series>>),
+        getSeries,
+        (std::int64_t series_id),
+        (const, override));
+
+    /// Mock retrieval of a card's series memberships.
+    MOCK_METHOD(
+        (mw::E<std::vector<std::int64_t>>),
+        getCardSeries,
+        (std::int64_t card_id),
+        (const, override));
+
+    /// Mock retrieval of all persisted game short names.
+    MOCK_METHOD(
+        (mw::E<std::vector<std::string>>),
+        getPersistedGameNames,
+        (),
+        (const, override));
+
+    /// Mock mutation of the stored schema version.
+    MOCK_METHOD(
+        (mw::E<void>),
+        setSchemaVersion,
+        (std::int64_t version),
+        (override));
+};
diff --git a/tests/data_mock_test.cpp b/tests/data_mock_test.cpp
new file mode 100644
index 0000000..64c79d5
--- /dev/null
+++ b/tests/data_mock_test.cpp
@@ -0,0 +1,10 @@
+#include "data_mock.h"
+
+#include <gtest/gtest.h>
+
+/// Verify that both mock classes are concrete implementations.
+TEST(DataMockTest, InterfacesCanBeInstantiated)
+{
+    DataSourceMock data_source;
+    DataSourceTransactionMock transaction;
+}