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