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,160 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Tombstones and compaction: erased rows disappear from iteration while
// RowIds stay put, Back() skips trailing tombstones, Compact renumbers
// densely and reclaims heap garbage, Clear empties, a stale temp file is
// removed on open, and views taken before Compact are not used after it.
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;
}
}
template<typename T>
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
if (!(actual == expected)) {
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
++failures;
}
}
struct Note {
std::string text;
std::int32_t rank;
static constexpr std::uint32_t version = 1;
static constexpr auto orderedBy = &Note::rank;
friend bool operator==(const Note&, const Note&) = default;
};
struct Memo {
std::optional<std::string> text;
std::int32_t id;
static constexpr std::uint32_t version = 1;
};
std::vector<std::int32_t> Ranks(const DB::Table<Note>& table) {
std::vector<std::int32_t> ranks;
for (DB::Row<Note> row : table) ranks.push_back(row[&Note::rank]);
return ranks;
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-compact";
fs::remove_all(dir);
const fs::path path = dir / "notes.cdb";
{
DB::Table<Note> table(path);
for (std::int32_t i = 0; i < 10; i++) table.Append({ std::format("note number {} with a long body", i), i });
const std::uint64_t heapBefore = table.Stats().heapUsed;
Check(table.Erase(2), "Erase returns true");
Check(!table.Erase(2), "second Erase returns false");
table.Erase(5);
table.Erase(9);
CheckEqual<std::uint64_t>(table.Size(), 7, "Size after erase");
CheckEqual<std::uint64_t>(table.SlotCount(), 10, "SlotCount unchanged");
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 3, 4, 6, 7, 8 }, "iteration skips tombstones");
Check(table[7][&Note::rank] == 7, "RowIds stable after erase");
Check(table.Back()[&Note::rank] == 8, "Back skips a trailing tombstone");
Check(table.Front()[&Note::rank] == 0, "Front");
Check(!table.Contains(2) && table.Contains(3) && !table.Contains(10), "Contains");
Check(table[2].Erased(), "Erased flag visible through Slots");
bool threw = false;
try {
table.At(2);
} catch (const std::out_of_range&) {
threw = true;
}
Check(threw, "At on an erased row throws");
Check(table.Stats().heapGarbage == 3 * std::string("note number 0 with a long body").size(), "erase accounts heap garbage");
CheckEqual<std::uint64_t>(table.EraseIf([](DB::Row<Note> r) { return r[&Note::rank] % 4 == 3; }), 2, "EraseIf count (3 and 7)");
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 4, 6, 8 }, "EraseIf result");
table.Update(4, { "four", 4 });
Check(table[4][&Note::text] == "four", "Update in place");
const std::uint64_t sizeBefore = fs::file_size(path);
table.Flush();
table.Compact();
CheckEqual<std::uint64_t>(table.Size(), 5, "Size after Compact");
CheckEqual<std::uint64_t>(table.SlotCount(), 5, "SlotCount after Compact");
CheckEqual(Ranks(table), std::vector<std::int32_t>{ 0, 1, 4, 6, 8 }, "order preserved by Compact");
Check(table[2][&Note::text] == "four", "rows renumbered densely");
Check(table[4][&Note::text] == "note number 8 with a long body", "heap values repacked correctly");
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, 0, "no garbage after Compact");
Check(table.Stats().heapUsed < heapBefore, "heap shrank");
Check(fs::file_size(path) <= sizeBefore, "file did not grow");
table.Append({ "after compact", 9 });
CheckEqual<std::uint64_t>(table.Size(), 6, "append after Compact");
CheckEqual<std::uint64_t>(table.Back().Id(), 5, "new row gets the next dense id");
Check(table.Back()[&Note::text] == "after compact", "append after Compact readable");
}
{
// A leftover temp file (crash during Compact) is cleaned on the next open.
const fs::path stale = fs::path(path.string() + ".tmp-deadbeef0000");
{ std::ofstream(stale) << "junk"; }
DB::Table<Note> table(path);
Check(!fs::exists(stale), "stale temp file removed on open");
CheckEqual<std::uint64_t>(table.Size(), 6, "data intact after reopen");
table.Clear();
CheckEqual<std::uint64_t>(table.Size(), 0, "Clear empties the table");
CheckEqual<std::uint64_t>(table.SlotCount(), 0, "Clear resets slots");
table.Append({ "fresh", 1 });
CheckEqual<std::uint64_t>(table.Back().Id(), 0, "ids restart after Clear");
}
{
const DB::Table<Note> table(path, { .readOnly = true });
CheckEqual<std::uint64_t>(table.Size(), 1, "Clear + append persisted");
Check(table[0][&Note::text] == "fresh", "row persisted");
Check(fs::file_size(path) <= 3 * 65536, std::format("Clear did not pre-size the new file to the old data ({} bytes)", fs::file_size(path)));
const auto plain = table.Rows(0, 1);
std::uint64_t seen = 0;
for (DB::Row<Note> row : plain) seen += row[&Note::rank];
CheckEqual<std::uint64_t>(seen, 1, "const Query over a LiveRange iterates");
CheckEqual<std::uint64_t>(plain.Count(), 1, "Count on a const Query");
CheckEqual<std::uint64_t>(table.Rows(0, 1).Count(), 1, "Count on a prvalue Query");
static_assert(std::ranges::range<const decltype(plain)>, "a Query over a plain range is const-iterable");
static_assert(!std::ranges::range<const decltype(table.Where([](DB::Row<Note>) { return true; }))>, "a filter-backed Query is not const-iterable (filter_view has no const begin)");
Check(table.Where([](DB::Row<Note> r) { return r[&Note::rank] == 1; }).Any(), "Any on a prvalue filter Query");
}
{
// A disengaged optional must not keep its old heap bytes alive.
const fs::path memos = dir / "memos.cdb";
DB::Table<Memo> table(memos);
table.Append({ std::string("a note long enough to live in the heap"), 1 });
const std::uint64_t used = table.Stats().heapUsed;
Check(used > 0, "optional string went to the heap");
table.Set<&Memo::text>(0, std::optional<std::string>());
Check(!table[0][&Memo::text].has_value(), "optional cleared");
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, used, "clearing the optional orphans its heap bytes");
table.Compact();
CheckEqual<std::uint64_t>(table.Stats().heapUsed, 0, "Compact dropped the dead payload");
DB::Var payload;
std::memcpy(&payload, table[0].Bytes() + DB::Layout<Memo>::Offset(1), sizeof(payload));
Check(payload.length == 0 && payload.tailOrOffset == 0, "payload handle is zero after clearing");
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}