Initial commit: Crafter.DB

Header-only C++26 module library for typed, file-backed tables with
schema reflection, migrations, queries, and crash-safe commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-17 04:16:51 +02:00
commit bfd0cc261b
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
34 changed files with 7506 additions and 0 deletions

View file

@ -0,0 +1,207 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A struct that changed without a version bump must be refused with the
// column difference in the message; so must files that are not tables, have
// a damaged header, or have both commit records corrupted.
import Crafter.DB;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
struct Original {
std::string name;
std::int64_t amount;
static constexpr std::uint32_t version = 1;
};
struct AddedColumn { // same version, one more member
std::string name;
std::int64_t amount;
std::string reference;
static constexpr std::uint32_t version = 1;
};
struct RenamedColumn { // same version, same layout, different name
std::string name;
std::int64_t total;
static constexpr std::uint32_t version = 1;
};
struct RetypedColumn { // same version, int64 -> int32
std::string name;
std::int32_t amount;
static constexpr std::uint32_t version = 1;
};
struct NowOrdered { // same columns, but ordered
std::string name;
std::int64_t amount;
static constexpr std::uint32_t version = 1;
static constexpr auto orderedBy = &NowOrdered::amount;
};
struct IntBlob {
std::vector<std::int32_t> values;
static constexpr std::uint32_t version = 1;
};
struct FloatBlob { // same bytes per element, different meaning
std::vector<float> values;
static constexpr std::uint32_t version = 1;
};
template<class T>
std::string ExpectSchemaError(const fs::path& path, std::string_view what) {
try {
DB::Table<T> table(path, { .readOnly = true });
} catch (const DB::SchemaError& error) {
return error.what();
} catch (const std::exception& error) {
std::println("FAIL: {} threw {} instead of SchemaError", what, error.what());
++failures;
return "";
}
std::println("FAIL: {} did not throw", what);
++failures;
return "";
}
void CheckOrFail(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
template<class E>
bool Throws(const fs::path& path) {
try {
DB::Table<Original> table(path, { .readOnly = true });
} catch (const E&) {
return true;
} catch (...) {
return false;
}
return false;
}
void Patch(const fs::path& path, std::uint64_t offset, std::span<const std::byte> bytes) {
std::fstream file(path, std::ios::in | std::ios::out | std::ios::binary);
file.seekp(static_cast<std::streamoff>(offset));
file.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-schema";
fs::remove_all(dir);
const fs::path path = dir / "o.cdb";
{
DB::Table<Original> table(path);
table.Append({ "x", 1 });
}
const std::string added = ExpectSchemaError<AddedColumn>(path, "added member");
Check(added.find("reference") != std::string::npos && added.find("without bumping") != std::string::npos, std::format("message names the new column: {}", added));
const std::string renamed = ExpectSchemaError<RenamedColumn>(path, "renamed member");
Check(renamed.find("total") != std::string::npos && renamed.find("amount") != std::string::npos, std::format("message shows both names: {}", renamed));
const std::string retyped = ExpectSchemaError<RetypedColumn>(path, "retyped member");
Check(retyped.find("i32") != std::string::npos && retyped.find("i64") != std::string::npos, std::format("message shows both kinds: {}", retyped));
const std::string ordered = ExpectSchemaError<NowOrdered>(path, "orderedBy added");
Check(ordered.find("orderedBy") != std::string::npos && ordered.find("amount") != std::string::npos, std::format("message names the ordered column change: {}", ordered));
{
const DB::Table<Original> fine(path, { .readOnly = true });
Check(fine.Size() == 1, "the original struct still opens");
}
{
const fs::path blob = dir / "blob.cdb";
{
DB::Table<IntBlob> ints(blob);
ints.Append({ { 1, 2, 3, 1065353216 } });
}
const std::string retypedBlob = ExpectSchemaError<FloatBlob>(blob, "blob element type changed");
Check(!retypedBlob.empty(), "vector<int32> -> vector<float> is refused");
}
{
// CreateNew on an existing file must refuse without touching it.
const DB::Inspection before = DB::TableFile::Inspect(path);
bool threw = false;
try {
DB::Table<Original> again(path, { .mode = DB::OpenMode::CreateNew });
} catch (const DB::IOError& error) {
threw = error.code == std::errc::file_exists;
}
Check(threw, "CreateNew on an existing table throws file_exists");
const DB::Inspection after = DB::TableFile::Inspect(path);
CheckOrFail(before.commit.commitSeq == after.commit.commitSeq, "CreateNew wrote nothing to the existing file");
}
// A descriptor whose presenceOffset points outside the row.
const fs::path presence = dir / "presence.cdb";
fs::copy_file(path, presence);
{
const DB::Inspection info = DB::TableFile::Inspect(presence);
// descriptor i starts at 4096 + 16 + i * 24; presenceOffset is at +6
const std::uint16_t bogus = 0xFFF0;
Patch(presence, 4096 + 16 + 1 * 24 + 6, std::as_bytes(std::span(&bogus, 1)));
Check(info.schema.columns.size() == 3, "fixture has three columns");
}
Check(Throws<DB::CorruptError>(presence), "an out-of-row presenceOffset throws CorruptError");
// Damaged magic.
const fs::path magic = dir / "magic.cdb";
fs::copy_file(path, magic);
const std::array<std::byte, 2> junk = { std::byte{ 'X' }, std::byte{ 'X' } };
Patch(magic, 0, junk);
Check(Throws<DB::CorruptError>(magic), "bad magic throws CorruptError");
// Both commit records damaged (flip a byte in each slot's sequence number).
const fs::path commits = dir / "commits.cdb";
fs::copy_file(path, commits);
const std::array<std::byte, 1> flip = { std::byte{ 0xFF } };
Patch(commits, 3072 + 1, flip);
Patch(commits, 3584 + 1, flip);
Check(Throws<DB::CorruptError>(commits), "two damaged commit records throw CorruptError");
// Not a table at all.
const fs::path random = dir / "random.cdb";
{
std::ofstream out(random, std::ios::binary);
std::string bytes(8192, '\0');
for (std::size_t i = 0; i < bytes.size(); i++) bytes[i] = static_cast<char>(i * 31 + 7);
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
Check(Throws<DB::CorruptError>(random), "random bytes throw CorruptError");
// Too short.
const fs::path tiny = dir / "tiny.cdb";
{ std::ofstream(tiny) << "short"; }
Check(Throws<DB::CorruptError>(tiny), "a tiny file throws CorruptError");
// Truncated below the committed length.
const fs::path truncated = dir / "truncated.cdb";
fs::copy_file(path, truncated);
fs::resize_file(truncated, 4096 * 2);
Check(Throws<DB::CorruptError>(truncated), "a truncated file throws CorruptError");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}