// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Migrations: a V1 file opened by the V3 struct is converted in one pass // through an additive by-name step (V1 -> V2) and an explicit Migrate // (V2 -> V3); a V2 file skips the first step; the backup is kept; the // refusal paths (read-only, migrate = false) throw MigrationError; the // explicit Migrate() entry point works without keeping the table open. import Crafter.DB; import std; using namespace Crafter; namespace fs = std::filesystem; using TimePoint = std::chrono::system_clock::time_point; namespace { int failures = 0; void Check(bool condition, std::string_view what) { if (!condition) { std::println("FAIL: {}", what); ++failures; } } template void CheckEqual(const T& actual, const T& expected, std::string_view what) { if (!(actual == expected)) { std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual); ++failures; } } enum class Currency : std::uint16_t { Euro, Dollar }; struct TransactionV1 { std::string name; std::string bic; std::string iban; std::string description; Currency currency; std::int64_t amount; TimePoint timePoint; static constexpr std::uint32_t version = 1; static constexpr auto orderedBy = &TransactionV1::timePoint; }; // Additive: `reference` appended, nothing else changed -> no Migrate. struct TransactionV2 { std::string name; std::string bic; std::string iban; std::string description; std::string reference; Currency currency; std::int64_t amount; TimePoint timePoint; static constexpr std::uint32_t version = 2; static constexpr auto orderedBy = &TransactionV2::timePoint; using Previous = TransactionV1; }; struct Money { std::int64_t minor; Currency currency; friend bool operator==(const Money&, const Money&) = default; }; // description -> memo, amount + currency -> money: needs Migrate. struct Transaction { std::string name; std::string bic; std::string iban; std::string memo; std::string reference; Money money; TimePoint timePoint; static constexpr std::uint32_t version = 3; static constexpr auto orderedBy = &Transaction::timePoint; using Previous = TransactionV2; static Transaction Migrate(TransactionV2&& old) { return { .name = std::move(old.name), .bic = std::move(old.bic), .iban = std::move(old.iban), .memo = std::move(old.description), .reference = std::move(old.reference), .money = { old.amount, old.currency }, .timePoint = old.timePoint, }; } }; struct EventV1 { std::string name; std::int64_t when; static constexpr std::uint32_t version = 1; }; struct EventV2 { std::string name; std::int64_t when; static constexpr std::uint32_t version = 2; static constexpr auto orderedBy = &EventV2::when; using Previous = EventV1; }; TimePoint At(std::int64_t seconds) { return TimePoint(std::chrono::duration_cast(std::chrono::seconds(seconds))); } void WriteV1(const fs::path& path) { DB::Table table(path); table.Append({ "Albert Heijn", "ABNANL2A", "NL91ABNA0417164300", "groceries", Currency::Euro, -2345, At(1) }); table.Append({ "Employer", "RABONL2U", "NL20RABO0123456789", "salary for a long month of work", Currency::Dollar, 300000, At(2) }); table.Append({ "Gone", "", "", "to be erased", Currency::Euro, 1, At(3) }); table.Append({ "Landlord", "INGBNL2A", "NL69INGB0123456789", "rent", Currency::Euro, -120000, At(4) }); table.Erase(2); } } int main() { const fs::path dir = fs::temp_directory_path() / "crafter-db-migrate"; fs::remove_all(dir); const fs::path path = dir / "tx.cdb"; WriteV1(path); { // Same struct, same version: no migration. const DB::Table same(path); CheckEqual(same.Size(), 3, "V1 reopens as V1"); } { bool threw = false; try { DB::Table refused(path, { .readOnly = true }); } catch (const DB::MigrationError&) { threw = true; } Check(threw, "read-only open of an older file throws MigrationError"); threw = false; try { DB::Table refused(path, { .migrate = false }); } catch (const DB::MigrationError&) { threw = true; } Check(threw, "migrate = false throws MigrationError"); CheckEqual(DB::PeekVersion(path), 1, "file untouched by refused migrations"); } { DB::Table table(path); CheckEqual(table.Size(), 3, "live rows carried over (tombstone dropped)"); CheckEqual(table.SlotCount(), 3, "migration compacts"); const std::vector rows = table.Materialize(); CheckEqual(rows[0].memo, "groceries", "description -> memo"); Check(rows[1].money == Money{ 300000, Currency::Dollar }, "amount + currency -> money"); CheckEqual(rows[1].reference, "", "new V2 column value-initialised"); CheckEqual(rows[2].iban, "NL69INGB0123456789", "strings carried"); Check(rows[2].timePoint == At(4), "time carried"); Check(table.Back()[&Transaction::name] == "Landlord", "view after migration"); const DB::Inspection info = DB::TableFile::Inspect(path); CheckEqual(info.header.schemaVersion, 3, "file now at version 3"); CheckEqual(info.header.migratedFromVersion, 1, "migratedFromVersion recorded"); Check(fs::exists(DB::TableFile::BackupPath(path, 1)), "backup of the V1 file kept"); CheckEqual(DB::PeekVersion(DB::TableFile::BackupPath(path, 1)), 1, "backup is the V1 file"); table.Append({ "After", "", "", "", "ref", { 5, Currency::Euro }, At(5) }); CheckEqual(table.Size(), 4, "append after migration"); } { // A V2 file skips the by-name step. const fs::path path2 = dir / "tx2.cdb"; { DB::Table v2(path2); v2.Append({ "A", "B", "C", "D", "REF-1", Currency::Euro, 7, At(1) }); } DB::Table table(path2, { .backupBeforeMigrate = false }); CheckEqual(table.Size(), 1, "V2 -> V3 row count"); Check(table[0][&Transaction::reference] == "REF-1", "V2 reference carried through Migrate"); Check(table[0][&Transaction::memo] == "D", "V2 description -> memo"); Check(!fs::exists(DB::TableFile::BackupPath(path2, 2)), "no backup when disabled"); } { // Explicit entry point and the V1 -> V2 by-name path on its own. const fs::path path3 = dir / "tx3.cdb"; WriteV1(path3); DB::Migrate(path3); CheckEqual(DB::PeekVersion(path3), 2, "explicit Migrate"); const DB::Table v2(path3, { .readOnly = true }); CheckEqual(v2.Size(), 3, "by-name migration keeps rows"); Check(v2[1][&TransactionV2::description] == "salary for a long month of work" && v2[1][&TransactionV2::reference] == "", "by-name copies and value-initialises"); } { // Old data that is not in the order the new struct declares is sorted // during the migration instead of failing it. const fs::path events = dir / "events.cdb"; { DB::Table v1(events); v1.Append({ "late", 20 }); v1.Append({ "early", 10 }); v1.Append({ "middle", 15 }); v1.Append({ "also early", 10 }); } DB::Table v2(events, { .backupBeforeMigrate = false }); CheckEqual(v2.Size(), 4, "all rows migrated"); std::vector order; for (DB::Row row : v2) order.push_back(row[&EventV2::when]); CheckEqual(order, std::vector{ 10, 10, 15, 20 }, "rows sorted by the new orderedBy"); Check(v2[0][&EventV2::name] == "early" && v2[1][&EventV2::name] == "also early", "sort is stable"); Check(v2.Verify().Ok(), "migrated table verifies"); } { // A file newer than the code is refused. bool threw = false; try { DB::Table older(path); } catch (const DB::SchemaError&) { threw = true; } Check(threw, "opening a V3 file with the V1 struct throws SchemaError"); } fs::remove_all(dir); if (failures == 0) std::println("ok"); return failures == 0 ? 0 : 1; }