// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Schema migrations in C++. A table struct names its predecessor with // `using Previous = OldStruct;` and either supplies // `static T Migrate(OldStruct&&)` or, for purely additive changes, nothing: // members are then copied by name and new ones value-initialised. Opening a // file whose version is older than the struct's walks the chain to the type // with that version and streams every live row through the conversions into // a new file that replaces the old one atomically. export module Crafter.DB:Migration; import std; import :Error; import :Reflect; import :Format; import :Formatter; import :Schema; import :File; import :Storage; import :Codec; export namespace Crafter::DB { template consteval std::size_t FindMemberByName() { constexpr std::string_view name = MemberName(); std::size_t found = noMember; [&](std::index_sequence) { ((void)[&] { if (MemberName() == name) found = J; }(), ...); }(std::make_index_sequence>{}); return found; } // Additive migration: every member of From must exist in To with the same // name and type; members only in To are value-initialised. template To CopyByName(From&& from) { To to{}; [&](std::index_sequence) { ((void)[&] { constexpr std::size_t j = FindMemberByName(); static_assert(j != noMember, Message({ "Crafter.DB: member '", MemberName(), "' of ", TypeName(), " has no member of the same name in ", TypeName(), "; a removed or renamed column needs an explicit `static ", TypeName(), " Migrate(", TypeName(), "&&)`." })); if constexpr (j != noMember) { static_assert(std::is_same_v, MemberType>, Message({ "Crafter.DB: member '", MemberName(), "' changed type between ", TypeName(), " and ", TypeName(), "; a type change needs an explicit `static ", TypeName(), " Migrate(", TypeName(), "&&)`." })); Member(to) = std::move(Member(from)); } }(), ...); }(std::make_index_sequence>{}); return to; } // One step up the chain: Previous -> Next. template Next ConvertStep(typename Next::Previous&& from) { if constexpr (HasMigrate) return Next::Migrate(std::move(from)); else return CopyByName(std::move(from)); } // The type directly above `From` in To's chain. template consteval auto NextAbove() { if constexpr (std::is_same_v) return std::type_identity{}; else return NextAbove(); } template To ConvertChain(From&& from) { if constexpr (std::is_same_v) return std::move(from); else { using Next = typename decltype(NextAbove())::type; Next next = ConvertStep(std::move(from)); return ConvertChain(std::move(next)); } } std::string DescribeColumns(const SchemaInfo& schema) { std::string out = "["; for (std::size_t i = 1; i < schema.columns.size(); i++) { if (i != 1) out += ", "; out += std::format("{}:{}", schema.Name(i), KindName(schema.columns[i].kind)); } return out + "]"; } template void RequireFingerprint(const std::filesystem::path& path, const FileHeader& header, const SchemaInfo& schema) { if (header.schemaFingerprint == Layout::fingerprint && header.rowSize == Layout::rowSize && header.orderedColumn == Layout::orderedColumn) return; const std::string onDisk = DescribeColumns(schema); const std::string compiled = DescribeColumns(MakeSchemaInfo()); if (onDisk == compiled && header.orderedColumn != Layout::orderedColumn) { const auto name = [&](std::uint16_t column) { return column == noColumn ? std::string("none") : std::string(schema.Name(column)); }; throw SchemaError(std::format("{}: {} version {} on disk is ordered by {} but the compiled struct is ordered by {}: `orderedBy` changed without bumping `version`", path.string(), TypeName(), header.schemaVersion, name(header.orderedColumn), name(Layout::orderedColumn))); } throw SchemaError(std::format("{}: {} version {} on disk has columns {} but the compiled struct has {}: the struct changed without bumping `version`", path.string(), TypeName(), header.schemaVersion, onDisk, compiled)); } // Writes the converted live rows of `old` into a fresh file and returns // its path. Streaming keeps memory flat; `sorted` collects everything and // stable-sorts by the new orderedBy first, for the case where the old data // is not in the order the new struct declares. template std::filesystem::path WriteConverted(const TableFile& old, const std::filesystem::path& path, const OpenOptions& options, bool sorted) { const TableStats stats = old.Stats(); CreateParams params; params.schema = MakeSchemaInfo(); params.schemaVersion = Layout::version; params.fingerprint = Layout::fingerprint; params.orderedColumn = Layout::orderedColumn; params.initialRowBytes = stats.liveCount * Layout::rowSize; params.initialHeapBytes = stats.heapUsed - std::min(stats.heapUsed, stats.heapGarbage); params.migratedFromVersion = old.Header().schemaVersion; OpenOptions targetOptions = options; targetOptions.readOnly = false; targetOptions.createDirectories = false; const std::filesystem::path temp = TableFile::TempPath(path); TableFile::Create(temp, params, targetOptions); try { TableFile target(temp, targetOptions); const std::uint64_t slots = old.SlotCount(); if (sorted) { std::vector rows; rows.reserve(stats.liveCount); for (std::uint64_t i = 0; i < slots; i++) { const std::byte* row = old.Slot(i); if (!TableFile::Deleted(row)) rows.push_back(ConvertChain(DecodeRow(row, old.HeapView()))); } if constexpr (HasOrderedBy) std::ranges::stable_sort(rows, {}, [](const T& row) { return row.*T::orderedBy; }); AppendRows(target, std::span(rows)); } else { for (std::uint64_t i = 0; i < slots; i++) { const std::byte* row = old.Slot(i); if (TableFile::Deleted(row)) continue; T converted = ConvertChain(DecodeRow(row, old.HeapView())); AppendRows(target, std::span(&converted, 1)); } } target.Flush(true); } catch (...) { Os::Remove(temp); throw; } return temp; } template void RewriteFrom(const std::filesystem::path& path, const OpenOptions& options) { OpenOptions sourceOptions = options; sourceOptions.readOnly = false; sourceOptions.mode = OpenMode::OpenExisting; TableFile old(path, sourceOptions); const std::uint32_t oldVersion = old.Header().schemaVersion; std::filesystem::path temp; try { temp = WriteConverted(old, path, options, false); } catch (const OrderError&) { if constexpr (!HasOrderedBy) throw; temp = WriteConverted(old, path, options, true); } try { if (options.backupBeforeMigrate) { const std::filesystem::path backup = TableFile::BackupPath(path, oldVersion); Os::Remove(backup); Os::HardLink(path, backup); } old.Close(); TableFile::Replace(path, temp); } catch (...) { Os::Remove(temp); throw; } } template bool WalkChain(const std::filesystem::path& path, const Inspection& info, const OpenOptions& options) { if (Layout::version == info.header.schemaVersion) { RequireFingerprint(path, info.header, info.schema); RewriteFrom(path, options); return true; } if constexpr (HasPrevious) return WalkChain(path, info, options); else return false; } template consteval std::uint32_t OldestVersion() { if constexpr (HasPrevious) return OldestVersion(); else return Layout::version; } // Brings a file whose version differs from T's up to date, or throws. template void MigrateTable(const std::filesystem::path& path, const Inspection& info, const OpenOptions& options) { const std::uint32_t fileVersion = info.header.schemaVersion; if (fileVersion == Layout::version) return; if (fileVersion > Layout::version) throw SchemaError(std::format("{}: file is at version {} but this build of {} is version {}; the file was written by a newer build", path.string(), fileVersion, TypeName(), Layout::version)); if (options.readOnly) throw MigrationError(std::format("{}: file is at version {} and {} is version {}, but the table was opened read-only", path.string(), fileVersion, TypeName(), Layout::version)); if (!options.migrate) throw MigrationError(std::format("{}: file is at version {} and {} is version {}, and migration was disabled", path.string(), fileVersion, TypeName(), Layout::version)); if (!WalkChain(path, info, options)) { throw SchemaError(std::format("{}: no migration path from on-disk version {} to {}: the Previous chain of {} stops at version {}", path.string(), fileVersion, Layout::version, TypeName(), OldestVersion())); } } // Explicit form: migrate the file at `path` to T without keeping it open. template void Migrate(const std::filesystem::path& path, const OpenOptions& options = {}) { MigrateTable(path, TableFile::Inspect(path), options); } std::uint32_t PeekVersion(const std::filesystem::path& path) { return TableFile::Inspect(path).header.schemaVersion; } }