BareGit

Add the SQLite data source

- Open and serialize access to the application SQLite database.
- Migrate fresh databases atomically to the version-one common schema.
- Read complete card records with nullable fields and invariant checks.
- Prepare and migrate persistence before server startup.
- Cover fresh startup, card reads, and corrupt records with focused tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Aug 22 16:45:52 2026 -0700
Commit: f51de2c09d133b3682ec5e5ca553b49e61cd938c

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index ae6da9a..0d42059 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,9 +18,10 @@ add_executable(
     card_collection
     src/app.cpp
     src/data.cpp
-    src/data_fake.cpp
+    src/data_sqlite.cpp
     src/main.cpp
     src/public_id.cpp
+    src/startup.cpp
     src/url_builder.cpp
 )
 
@@ -138,4 +139,29 @@ if(CARD_COLLECTION_BUILD_TESTS)
             mw::mw
     )
     gtest_discover_tests(data_fake_test)
+
+    add_executable(
+        data_sqlite_test
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/startup.cpp
+        tests/data_sqlite_test.cpp
+    )
+    target_compile_features(data_sqlite_test PRIVATE cxx_std_23)
+    set_target_properties(data_sqlite_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        data_sqlite_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        data_sqlite_test
+        PRIVATE
+            GTest::gtest_main
+            mw::mw
+            mw::sqlite
+            spdlog::spdlog
+    )
+    gtest_discover_tests(data_sqlite_test)
 endif()
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
new file mode 100644
index 0000000..1120bb7
--- /dev/null
+++ b/src/data_sqlite.cpp
@@ -0,0 +1,323 @@
+#include "data_sqlite.h"
+
+#include <array>
+#include <cstdint>
+#include <limits>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <tuple>
+#include <utility>
+#include <vector>
+
+#include <spdlog/spdlog.h>
+
+namespace
+{
+
+mw::Error notImplemented(std::string_view operation)
+{
+    return mw::runtimeError(
+        "DataSourceSQLite has not implemented " +
+        std::string(operation) + ".");
+}
+
+mw::E<void> rollbackWithError(
+    mw::SQLite& connection,
+    mw::Error error)
+{
+    auto rollback = connection.execute("ROLLBACK;");
+    if(!rollback)
+    {
+        spdlog::error(
+            "Failed to roll back schema migration: {}",
+            rollback.error().msg());
+    }
+    return std::unexpected(std::move(error));
+}
+
+const std::array<std::string_view, 7> SCHEMA_VERSION_1_STATEMENTS = {
+    R"sql(
+        CREATE TABLE cards (
+            id INTEGER PRIMARY KEY,
+            game_short_name TEXT,
+            card_number INTEGER NOT NULL,
+            name TEXT NOT NULL,
+            short_description TEXT,
+            long_description TEXT,
+            rarity INTEGER NOT NULL DEFAULT 0 CHECK(rarity >= 0),
+            front_extension TEXT NOT NULL
+                CHECK(front_extension IN ('jpg', 'jpeg', 'webp', 'avif')),
+            foil_extension TEXT
+                CHECK(foil_extension IN ('webp', 'avif')),
+            thumbnail_extension TEXT NOT NULL
+                CHECK(thumbnail_extension IN
+                      ('jpg', 'jpeg', 'webp', 'avif')),
+            revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+            CHECK(
+                (game_short_name IS NULL
+                 AND card_number >= 0
+                 AND card_number <= 4294967295)
+                OR
+                (game_short_name IS NOT NULL AND card_number >= 1)
+            )
+        );
+    )sql",
+    R"sql(
+        CREATE UNIQUE INDEX cards_game_number_unique
+        ON cards(game_short_name, card_number)
+        WHERE game_short_name IS NOT NULL;
+    )sql",
+    R"sql(
+        CREATE UNIQUE INDEX cards_loose_number_unique
+        ON cards(card_number)
+        WHERE game_short_name IS NULL;
+    )sql",
+    R"sql(
+        CREATE TABLE game_sequences (
+            game_short_name TEXT PRIMARY KEY,
+            last_number INTEGER NOT NULL CHECK(last_number >= 0)
+        );
+    )sql",
+    R"sql(
+        CREATE TABLE series (
+            id INTEGER PRIMARY KEY,
+            game_short_name TEXT NOT NULL,
+            name TEXT NOT NULL,
+            description TEXT NOT NULL DEFAULT '',
+            UNIQUE(game_short_name, name)
+        );
+    )sql",
+    R"sql(
+        CREATE TABLE card_series (
+            card_id INTEGER NOT NULL
+                REFERENCES cards(id) ON DELETE CASCADE,
+            series_id INTEGER NOT NULL
+                REFERENCES series(id) ON DELETE CASCADE,
+            PRIMARY KEY(card_id, series_id)
+        );
+    )sql",
+    R"sql(
+        CREATE TRIGGER card_series_same_game_insert
+        BEFORE INSERT ON card_series
+        BEGIN
+            SELECT CASE
+                WHEN (SELECT game_short_name
+                      FROM cards
+                      WHERE id = NEW.card_id) IS NULL
+                THEN RAISE(ABORT, 'loose card cannot belong to a series')
+                WHEN (SELECT game_short_name
+                      FROM cards
+                      WHERE id = NEW.card_id)
+                     !=
+                     (SELECT game_short_name
+                      FROM series
+                      WHERE id = NEW.series_id)
+                THEN RAISE(ABORT, 'card and series games differ')
+            END;
+        END;
+    )sql",
+};
+
+} // namespace
+
+DataSourceSQLite::DataSourceSQLite(
+    std::unique_ptr<mw::SQLite> connection)
+        : connection_(std::move(connection))
+{}
+
+mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
+    const std::filesystem::path& database_path)
+{
+    auto connection = mw::SQLite::connectFile(database_path.string());
+    if(!connection)
+    {
+        return std::unexpected(std::move(connection.error()));
+    }
+    auto synchronous_result =
+        (*connection)->execute("PRAGMA synchronous = NORMAL;");
+    if(!synchronous_result)
+    {
+        return std::unexpected(std::move(synchronous_result.error()));
+    }
+    return std::unique_ptr<DataSourceSQLite>(
+        new DataSourceSQLite(std::move(*connection)));
+}
+
+mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
+{
+    std::lock_guard lock(mutex_);
+    return connection_->evalToValue<std::int64_t>("PRAGMA user_version;");
+}
+
+mw::E<void> DataSourceSQLite::migrateSchema0To1(
+    [[maybe_unused]] const GameRegistry& games)
+{
+    std::lock_guard lock(mutex_);
+    auto version = connection_->evalToValue<std::int64_t>(
+        "PRAGMA user_version;");
+    if(!version)
+    {
+        return std::unexpected(std::move(version.error()));
+    }
+    if(*version != 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Schema version 0 to 1 migration requires version 0"));
+    }
+
+    auto begin = connection_->execute("BEGIN IMMEDIATE;");
+    if(!begin)
+    {
+        return std::unexpected(std::move(begin.error()));
+    }
+    for(std::string_view statement : SCHEMA_VERSION_1_STATEMENTS)
+    {
+        auto result = connection_->execute(std::string(statement));
+        if(!result)
+        {
+            return rollbackWithError(
+                *connection_, std::move(result.error()));
+        }
+    }
+
+    auto set_version = connection_->execute("PRAGMA user_version = 1;");
+    if(!set_version)
+    {
+        return rollbackWithError(
+            *connection_, std::move(set_version.error()));
+    }
+    auto commit = connection_->execute("COMMIT;");
+    if(!commit)
+    {
+        return rollbackWithError(*connection_, std::move(commit.error()));
+    }
+    return {};
+}
+
+mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+DataSourceSQLite::beginTransaction()
+{
+    return std::unexpected(notImplemented("transactions"));
+}
+
+mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
+{
+    std::lock_guard lock(mutex_);
+    auto rows = connection_->eval<
+        std::int64_t,
+        std::optional<std::string>,
+        std::int64_t,
+        std::string,
+        std::optional<std::string>,
+        std::optional<std::string>,
+        std::int64_t,
+        std::string,
+        std::optional<std::string>,
+        std::string,
+        std::int64_t>(
+            "SELECT id, game_short_name, card_number, name, "
+            "short_description, long_description, rarity, "
+            "front_extension, foil_extension, thumbnail_extension, "
+            "revision FROM cards ORDER BY id;");
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+
+    std::vector<Card> cards;
+    cards.reserve(rows->size());
+    for(auto& row : *rows)
+    {
+        auto& [
+            id,
+            game_short_name,
+            card_number,
+            name,
+            short_description,
+            long_description,
+            rarity,
+            front_extension,
+            foil_extension,
+            thumbnail_extension,
+            revision] = row;
+        const bool invalid_loose_number =
+            !game_short_name &&
+            card_number > std::numeric_limits<std::uint32_t>::max();
+        const bool invalid_game_number =
+            game_short_name &&
+            (game_short_name->empty() || card_number == 0);
+        if(card_number < 0 || invalid_loose_number || invalid_game_number ||
+           rarity < 0 || revision < 1)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid card record"));
+        }
+
+        cards.push_back({
+            id,
+            {std::move(game_short_name),
+             static_cast<std::uint64_t>(card_number)},
+            std::move(name),
+            std::move(short_description),
+            std::move(long_description),
+            rarity,
+            std::move(front_extension),
+            std::move(foil_extension),
+            std::move(thumbnail_extension),
+            revision,
+        });
+    }
+    return cards;
+}
+
+mw::E<std::optional<Card>> DataSourceSQLite::getCard(
+    [[maybe_unused]] const CardIdentity& identity) const
+{
+    return std::unexpected(notImplemented("card reads"));
+}
+
+mw::E<std::vector<DisplayField>>
+DataSourceSQLite::getGameDisplayFields(
+    [[maybe_unused]] const GameDefinition& game,
+    [[maybe_unused]] std::int64_t card_id) const
+{
+    return std::unexpected(notImplemented("game display-field reads"));
+}
+
+mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
+{
+    return std::unexpected(notImplemented("series index reads"));
+}
+
+mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
+    [[maybe_unused]] std::int64_t series_id) const
+{
+    return std::unexpected(notImplemented("series reads"));
+}
+
+mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
+    [[maybe_unused]] std::int64_t card_id) const
+{
+    return std::unexpected(notImplemented("card-series reads"));
+}
+
+mw::E<std::vector<std::string>>
+DataSourceSQLite::getPersistedGameNames() const
+{
+    return std::unexpected(notImplemented("persisted-game reads"));
+}
+
+mw::E<void> DataSourceSQLite::setSchemaVersion(
+    std::int64_t version)
+{
+    if(version < 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database schema version cannot be negative"));
+    }
+    std::lock_guard lock(mutex_);
+    return connection_->execute(
+        "PRAGMA user_version = " + std::to_string(version) + ";");
+}
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
new file mode 100644
index 0000000..9b97850
--- /dev/null
+++ b/src/data_sqlite.h
@@ -0,0 +1,75 @@
+#pragma once
+
+#include <filesystem>
+#include <memory>
+#include <mutex>
+
+#include <mw/database.hpp>
+
+#include "data.h"
+
+/// SQLite-backed implementation of the application persistence boundary.
+class DataSourceSQLite final : public DataSourceInterface
+{
+public:
+    /// Destroy the data source after all transactions have ended.
+    ~DataSourceSQLite() override = default;
+
+    /// Prevent copying a data source and its single owned connection.
+    DataSourceSQLite(const DataSourceSQLite&) = delete;
+
+    /// Prevent copy assignment of a data source connection.
+    DataSourceSQLite& operator=(const DataSourceSQLite&) = delete;
+
+    /// Open a SQLite database file without applying migrations.
+    static mw::E<std::unique_ptr<DataSourceSQLite>>
+    fromFile(const std::filesystem::path& database_path);
+
+    /// Return the stored schema version.
+    mw::E<std::int64_t> getSchemaVersion() const override;
+
+    /// Create schema version 1 from an empty version-0 database.
+    mw::E<void>
+    migrateSchema0To1(const GameRegistry& games) override;
+
+    /// Start an immediate transaction with exclusive mutation ownership.
+    mw::E<std::unique_ptr<DataSourceTransactionInterface>>
+    beginTransaction() override;
+
+    /// Return all cards for the unpaginated index.
+    mw::E<std::vector<Card>> getCards() const override;
+
+    /// Return a card by its parsed identity.
+    mw::E<std::optional<Card>>
+    getCard(const CardIdentity& identity) const override;
+
+    /// Return a card's game-owned display fields.
+    mw::E<std::vector<DisplayField>> getGameDisplayFields(
+        const GameDefinition& game,
+        std::int64_t card_id) const override;
+
+    /// Return all series, ordered by game and name.
+    mw::E<std::vector<Series>> getSeries() const override;
+
+    /// Return one series by internal ID.
+    mw::E<std::optional<Series>>
+    getSeries(std::int64_t series_id) const override;
+
+    /// Return the series memberships for one card.
+    mw::E<std::vector<std::int64_t>>
+    getCardSeries(std::int64_t card_id) const override;
+
+    /// Return every persisted game name used for startup reconciliation.
+    mw::E<std::vector<std::string>>
+    getPersistedGameNames() const override;
+
+protected:
+    /// Set the schema version inside a concrete migration transaction.
+    mw::E<void> setSchemaVersion(std::int64_t version) override;
+
+private:
+    explicit DataSourceSQLite(std::unique_ptr<mw::SQLite> connection);
+
+    std::unique_ptr<mw::SQLite> connection_;
+    mutable std::mutex mutex_;
+};
diff --git a/src/game_registry.h b/src/game_registry.h
new file mode 100644
index 0000000..5dd744b
--- /dev/null
+++ b/src/game_registry.h
@@ -0,0 +1,12 @@
+#pragma once
+
+/// Immutable collection of compiled game definitions.
+///
+/// Production currently registers no games. Lookup and schema-extension
+/// behavior will be added with the first compiled game definition.
+class GameRegistry
+{
+public:
+    /// Construct the empty production registry.
+    GameRegistry() = default;
+};
diff --git a/src/main.cpp b/src/main.cpp
index 0fc1186..a145721 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,18 +1,15 @@
 #include <chrono>
 #include <csignal>
-#include <cstdint>
 #include <filesystem>
-#include <memory>
-#include <optional>
 #include <string>
 #include <thread>
 #include <utility>
-#include <vector>
 
 #include <spdlog/spdlog.h>
 
 #include "app.h"
-#include "data_fake.h"
+#include "game_registry.h"
+#include "startup.h"
 
 namespace
 {
@@ -28,38 +25,6 @@ void handleSignal([[maybe_unused]] int signal)
     STOP_REQUESTED = 1;
 }
 
-Card makeCard(
-    std::int64_t id,
-    std::uint32_t number,
-    std::string name,
-    std::int64_t rarity)
-{
-    return {
-        id,
-        {std::nullopt, number},
-        std::move(name),
-        std::nullopt,
-        std::nullopt,
-        rarity,
-        "avif",
-        std::nullopt,
-        "avif",
-        1,
-    };
-}
-
-std::vector<Card> sampleCards()
-{
-    return {
-        makeCard(1, 2, "Frost Garden", 1),
-        makeCard(2, 10, "Midnight Relay", 3),
-        makeCard(3, 42, "Glass Comet", 2),
-        makeCard(4, 128, "Signal Bloom", 0),
-        makeCard(5, 1024, "Quiet Orbit", 4),
-        makeCard(6, 65535, "Last Light", 5),
-    };
-}
-
 mw::E<Config> makeDevelopmentConfig()
 {
     const std::string base_url_text =
@@ -95,7 +60,7 @@ mw::E<Config> makeDevelopmentConfig()
 
 } // namespace
 
-/// Start the Card Collection development server with deterministic fake data.
+/// Start the Card Collection development server with its SQLite database.
 int main()
 {
     auto config = makeDevelopmentConfig();
@@ -107,9 +72,17 @@ int main()
         return 1;
     }
 
-    App app(
-        *config,
-        std::make_unique<DataSourceFake>(sampleCards()));
+    GameRegistry games;
+    auto data_source = prepareDataSource(config->database_path, games);
+    if(!data_source)
+    {
+        spdlog::error(
+            "Failed to prepare the card database: {}",
+            data_source.error().msg());
+        return 1;
+    }
+
+    App app(*config, std::move(*data_source));
 
     std::signal(SIGINT, handleSignal);
     std::signal(SIGTERM, handleSignal);
diff --git a/src/startup.cpp b/src/startup.cpp
new file mode 100644
index 0000000..8646751
--- /dev/null
+++ b/src/startup.cpp
@@ -0,0 +1,22 @@
+#include "startup.h"
+
+#include <utility>
+
+#include "data_sqlite.h"
+
+mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
+    const std::filesystem::path& database_path,
+    const GameRegistry& games)
+{
+    auto data_source = DataSourceSQLite::fromFile(database_path);
+    if(!data_source)
+    {
+        return std::unexpected(std::move(data_source.error()));
+    }
+    auto migration_result = (*data_source)->migrateToLatest(games);
+    if(!migration_result)
+    {
+        return std::unexpected(std::move(migration_result.error()));
+    }
+    return std::move(*data_source);
+}
diff --git a/src/startup.h b/src/startup.h
new file mode 100644
index 0000000..d6d46cb
--- /dev/null
+++ b/src/startup.h
@@ -0,0 +1,13 @@
+#pragma once
+
+#include <filesystem>
+#include <memory>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+
+/// Open and migrate the application data source before server startup.
+mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
+    const std::filesystem::path& database_path,
+    const GameRegistry& games);
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
new file mode 100644
index 0000000..4990556
--- /dev/null
+++ b/tests/data_sqlite_test.cpp
@@ -0,0 +1,168 @@
+#include "data_sqlite.h"
+#include "game_registry.h"
+#include "startup.h"
+
+#include <chrono>
+#include <filesystem>
+#include <memory>
+#include <string>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+    /// Allocate a unique temporary database path for one test.
+    TemporaryDatabase()
+            : path_(
+                std::filesystem::path(testing::TempDir()) /
+                ("card_collection_" + std::to_string(
+                    std::chrono::steady_clock::now()
+                        .time_since_epoch().count()) + ".sqlite3"))
+    {}
+
+    /// Remove the test database and its SQLite sidecar files.
+    ~TemporaryDatabase()
+    {
+        std::error_code error;
+        std::filesystem::remove(path_, error);
+        std::filesystem::remove(path_.string() + "-shm", error);
+        std::filesystem::remove(path_.string() + "-wal", error);
+    }
+
+    /// Return the allocated database path.
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+} // namespace
+
+/// Verify the SQLite factory owns an opened connection.
+TEST(DataSourceSQLiteTest, OpensDatabase)
+{
+    auto data_source = DataSourceSQLite::fromFile(":memory:");
+
+    ASSERT_TRUE(data_source);
+    EXPECT_NE(*data_source, nullptr);
+}
+
+/// Verify unfinished persistence operations report explicit errors.
+TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
+{
+    auto data_source_result = DataSourceSQLite::fromFile(":memory:");
+    ASSERT_TRUE(data_source_result);
+    std::unique_ptr<DataSourceSQLite> data_source =
+        std::move(*data_source_result);
+
+    EXPECT_FALSE(data_source->beginTransaction());
+    EXPECT_FALSE(data_source->getCard({std::nullopt, 1}));
+    EXPECT_FALSE(data_source->getSeries());
+    EXPECT_FALSE(data_source->getSeries(1));
+    EXPECT_FALSE(data_source->getCardSeries(1));
+    EXPECT_FALSE(data_source->getPersistedGameNames());
+}
+
+/// Verify startup prepares a fresh database for its first card-index read.
+TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+
+    auto version = (*data_source)->getSchemaVersion();
+    ASSERT_TRUE(version);
+    EXPECT_EQ(*version, DB_SCHEMA_VERSION);
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    EXPECT_TRUE(cards->empty());
+}
+
+/// Verify card index reads preserve common and nullable card fields.
+TEST(DataSourceSQLiteTest, ReturnsCards)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE cards ("
+        "id INTEGER PRIMARY KEY, "
+        "game_short_name TEXT, "
+        "card_number INTEGER NOT NULL, "
+        "name TEXT NOT NULL, "
+        "short_description TEXT, "
+        "long_description TEXT, "
+        "rarity INTEGER NOT NULL, "
+        "front_extension TEXT NOT NULL, "
+        "foil_extension TEXT, "
+        "thumbnail_extension TEXT NOT NULL, "
+        "revision INTEGER NOT NULL);"));
+    ASSERT_TRUE((*connection)->execute(
+        "INSERT INTO cards VALUES (2, 'pkm', 7, 'Moon card', "
+        "'Short', 'Long', 4, 'avif', 'webp', 'avif', 3);"));
+    ASSERT_TRUE((*connection)->execute(
+        "INSERT INTO cards VALUES (1, NULL, 35, 'Loose card', "
+        "NULL, NULL, 0, 'jpg', NULL, 'webp', 1);"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 2);
+
+    EXPECT_EQ((*cards)[0].id, 1);
+    EXPECT_EQ((*cards)[0].identity.game_short_name, std::nullopt);
+    EXPECT_EQ((*cards)[0].identity.card_number, 35);
+    EXPECT_EQ((*cards)[0].name, "Loose card");
+    EXPECT_EQ((*cards)[0].short_description, std::nullopt);
+    EXPECT_EQ((*cards)[0].long_description, std::nullopt);
+    EXPECT_EQ((*cards)[0].rarity, 0);
+    EXPECT_EQ((*cards)[0].front_extension, "jpg");
+    EXPECT_EQ((*cards)[0].foil_extension, std::nullopt);
+    EXPECT_EQ((*cards)[0].thumbnail_extension, "webp");
+    EXPECT_EQ((*cards)[0].revision, 1);
+
+    EXPECT_EQ((*cards)[1].id, 2);
+    EXPECT_EQ((*cards)[1].identity.game_short_name, "pkm");
+    EXPECT_EQ((*cards)[1].identity.card_number, 7);
+    EXPECT_EQ((*cards)[1].name, "Moon card");
+    EXPECT_EQ((*cards)[1].short_description, "Short");
+    EXPECT_EQ((*cards)[1].long_description, "Long");
+    EXPECT_EQ((*cards)[1].rarity, 4);
+    EXPECT_EQ((*cards)[1].front_extension, "avif");
+    EXPECT_EQ((*cards)[1].foil_extension, "webp");
+    EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
+    EXPECT_EQ((*cards)[1].revision, 3);
+}
+
+/// Verify invalid persisted card identities fail the complete read.
+TEST(DataSourceSQLiteTest, RejectsInvalidCards)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE cards ("
+        "id INTEGER, game_short_name TEXT, card_number INTEGER, "
+        "name TEXT, short_description TEXT, long_description TEXT, "
+        "rarity INTEGER, front_extension TEXT, foil_extension TEXT, "
+        "thumbnail_extension TEXT, revision INTEGER);"));
+    ASSERT_TRUE((*connection)->execute(
+        "INSERT INTO cards VALUES (1, NULL, -1, 'Broken', NULL, NULL, "
+        "0, 'jpg', NULL, 'jpg', 1);"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    EXPECT_FALSE((*data_source)->getCards());
+}