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

245
tests/Benchmark/main.cpp Normal file
View file

@ -0,0 +1,245 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Crafter.DB against a cleaned-up copy of Nostro's hand-rolled format
// (iostream, uint16-prefixed strings inline, linear scans) on the five
// operations Nostro performs. Prints a table; never fails. Runs on 100k
// rows so a full suite stays fast; raise `rowCount` for a serious run.
import Crafter.DB;
import std;
using namespace Crafter;
namespace fs = std::filesystem;
using TimePoint = std::chrono::system_clock::time_point;
using Clock = std::chrono::steady_clock;
namespace {
enum class Currency : std::uint16_t { Euro };
struct Transaction {
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 = &Transaction::timePoint;
};
inline constexpr DB::Column<&Transaction::iban> iban{};
inline constexpr DB::Column<&Transaction::timePoint> timePoint{};
constexpr std::uint64_t rowCount = 100'000;
std::vector<Transaction> MakeRows() {
std::mt19937_64 rng(42);
const std::array<std::string_view, 6> names = { "Albert Heijn", "Jumbo Supermarkten", "NS Reizigers", "Vattenfall", "Bol.com", "Gemeente Utrecht" };
const std::array<std::string_view, 4> bics = { "ABNANL2A", "RABONL2U", "INGBNL2A", "TRIONL2U" };
std::vector<std::string> ibans;
for (std::uint32_t i = 0; i < 1000; i++) ibans.push_back(std::format("NL{:02}ABNA{:010}", i % 97, 417164300u + i));
std::vector<Transaction> rows;
rows.reserve(rowCount);
for (std::uint64_t i = 0; i < rowCount; i++) {
rows.push_back({ std::string(names[rng() % names.size()]), std::string(bics[rng() % bics.size()]), ibans[rng() % ibans.size()], std::format("payment {} for order {}", i, rng() % 100000), Currency::Euro, static_cast<std::int64_t>(rng() % 200000) - 100000, TimePoint(std::chrono::microseconds(1'700'000'000'000'000 + i * 60'000'000)) });
}
return rows;
}
// --- the Nostro format, cleaned up -----------------------------------------
namespace Nostro {
void WriteString(std::ofstream& file, std::string_view str) {
const std::uint16_t size = static_cast<std::uint16_t>(str.size());
file.write(reinterpret_cast<const char*>(&size), sizeof(size));
file.write(str.data(), size);
}
void ReadString(std::ifstream& file, std::string& str) {
std::uint16_t size;
file.read(reinterpret_cast<char*>(&size), sizeof(size));
str.resize(size);
file.read(str.data(), size);
}
void Save(const fs::path& path, std::span<const Transaction> rows) {
std::ofstream file(path, std::ios::binary | std::ios::app);
for (const Transaction& tr : rows) {
WriteString(file, tr.name);
WriteString(file, tr.bic);
WriteString(file, tr.iban);
WriteString(file, tr.description);
file.write(reinterpret_cast<const char*>(&tr.amount), sizeof(tr.amount));
file.write(reinterpret_cast<const char*>(&tr.currency), sizeof(Currency));
const std::int64_t time = std::chrono::duration_cast<std::chrono::microseconds>(tr.timePoint.time_since_epoch()).count();
file.write(reinterpret_cast<const char*>(&time), sizeof(time));
}
}
bool Load(std::ifstream& file, Transaction& tr) {
if (file.peek() == std::ifstream::traits_type::eof()) return false;
ReadString(file, tr.name);
ReadString(file, tr.bic);
ReadString(file, tr.iban);
ReadString(file, tr.description);
file.read(reinterpret_cast<char*>(&tr.amount), sizeof(tr.amount));
file.read(reinterpret_cast<char*>(&tr.currency), sizeof(tr.currency));
std::int64_t time;
file.read(reinterpret_cast<char*>(&time), sizeof(time));
tr.timePoint = TimePoint(std::chrono::microseconds(time));
return true;
}
TimePoint LastTime(const fs::path& path) {
std::ifstream file(path, std::ios::binary);
file.seekg(-static_cast<std::streamoff>(sizeof(std::int64_t)), std::ios::end);
std::int64_t time;
file.read(reinterpret_cast<char*>(&time), sizeof(time));
return TimePoint(std::chrono::microseconds(time));
}
std::vector<Transaction> LoadAll(const fs::path& path) {
std::ifstream file(path, std::ios::binary);
std::vector<Transaction> out;
Transaction tr;
while (Load(file, tr)) out.push_back(tr);
return out;
}
std::vector<Transaction> LoadRange(const fs::path& path, TimePoint from, TimePoint to) {
std::ifstream file(path, std::ios::binary);
std::vector<Transaction> out;
Transaction tr;
while (Load(file, tr)) {
if (tr.timePoint >= from && tr.timePoint <= to) out.push_back(tr);
if (tr.timePoint > to) break;
}
return out;
}
std::vector<Transaction> LoadByIban(const fs::path& path, std::string_view wanted) {
std::ifstream file(path, std::ios::binary);
std::vector<Transaction> out;
Transaction tr;
while (Load(file, tr)) {
if (tr.iban == wanted) out.push_back(tr);
}
return out;
}
}
template<class F>
double Millis(F&& f) {
const Clock::time_point start = Clock::now();
f();
return std::chrono::duration<double, std::milli>(Clock::now() - start).count();
}
struct Line {
std::string name;
double nostro;
double crafter;
std::string note;
};
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-benchmark";
fs::remove_all(dir);
fs::create_directories(dir);
const fs::path nostroPath = dir / "nostro.bin";
const fs::path crafterPath = dir / "crafter.cdb";
const std::vector<Transaction> rows = MakeRows();
const std::string_view wantedIban = rows[rowCount / 2].iban;
const TimePoint from = rows[rowCount * 45 / 100].timePoint;
const TimePoint to = rows[rowCount * 55 / 100].timePoint;
std::vector<Line> lines;
std::size_t sink = 0;
// append
{
const double nostro = Millis([&] { Nostro::Save(nostroPath, rows); });
const double crafter = Millis([&] {
DB::Table<Transaction> table(crafterPath);
table.Append(rows);
table.Flush();
});
lines.push_back({ std::format("append {} rows", rowCount), nostro, crafter, "incl. open, Crafter.DB incl. fdatasync" });
}
// last timestamp (1000 opens each)
{
const double nostro = Millis([&] { for (std::int32_t i = 0; i < 1000; i++) sink += Nostro::LastTime(nostroPath).time_since_epoch().count() & 1; });
const double crafter = Millis([&] {
for (std::int32_t i = 0; i < 1000; i++) {
const DB::Table<Transaction> table(crafterPath, { .readOnly = true });
sink += table.Back()[&Transaction::timePoint].time_since_epoch().count() & 1;
}
});
lines.push_back({ "last timestamp (1000x open+read)", nostro, crafter, "open dominates both" });
}
DB::Table<Transaction> table(crafterPath, { .readOnly = true });
// load all
{
const double nostro = Millis([&] { sink += Nostro::LoadAll(nostroPath).size(); });
const double crafter = Millis([&] { sink += table.Materialize().size(); });
double zeroCopy = Millis([&] {
std::int64_t total = 0;
for (DB::Row<Transaction> row : table) total += row[&Transaction::amount] + static_cast<std::int64_t>(row[&Transaction::iban].Length());
sink += static_cast<std::size_t>(total & 1);
});
lines.push_back({ "load all (materialised)", nostro, crafter, std::format("zero-copy scan of two columns: {:.2f} ms", zeroCopy) });
}
// time range (10% of rows)
{
std::size_t nostroCount = 0;
std::size_t crafterCount = 0;
const double nostro = Millis([&] { nostroCount = Nostro::LoadRange(nostroPath, from, to).size(); });
const double crafter = Millis([&] { crafterCount = table.Range<&Transaction::timePoint>(from, to).Materialize().size(); });
lines.push_back({ "time range (10% of rows, materialised)", nostro, crafter, std::format("{} / {} rows", nostroCount, crafterCount) });
}
// iban ==
{
std::size_t nostroCount = 0;
std::size_t keyCount = 0;
std::size_t exprCount = 0;
std::size_t viewCount = 0;
const double nostro = Millis([&] { nostroCount = Nostro::LoadByIban(nostroPath, wantedIban).size(); });
const double withKey = Millis([&] {
const DB::Key key(wantedIban);
keyCount = table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == key; }).Count();
});
const double withExpression = Millis([&] { exprCount = table.Where(iban == wantedIban).Count(); });
const double withView = Millis([&] { viewCount = table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == wantedIban; }).Count(); });
lines.push_back({ "iban == x (lambda + Key)", nostro, withKey, std::format("{} / {} rows, {:.1f} ns/row", nostroCount, keyCount, withKey * 1e6 / rowCount) });
lines.push_back({ "iban == x (expression)", nostro, withExpression, std::format("{} rows, {:.1f} ns/row", exprCount, withExpression * 1e6 / rowCount) });
lines.push_back({ "iban == x (lambda + string_view)", nostro, withView, std::format("{} rows, {:.1f} ns/row", viewCount, withView * 1e6 / rowCount) });
}
// range + iban
{
std::size_t nostroCount = 0;
std::size_t crafterCount = 0;
const double nostro = Millis([&] {
for (const Transaction& tr : Nostro::LoadRange(nostroPath, from, to)) nostroCount += tr.iban == wantedIban ? 1 : 0;
});
const double crafter = Millis([&] { crafterCount = table.Where(timePoint >= from && timePoint <= to && iban == wantedIban).Count(); });
lines.push_back({ "time range + iban == x", nostro, crafter, std::format("{} / {} rows", nostroCount, crafterCount) });
}
std::println("{:<42} {:>12} {:>12} {:>8} {}", "operation", "nostro ms", "crafter ms", "speedup", "note");
for (const Line& line : lines) {
std::println("{:<42} {:>12.3f} {:>12.3f} {:>7.1f}x {}", line.name, line.nostro, line.crafter, line.nostro / std::max(line.crafter, 0.0001), line.note);
}
std::println("files: nostro {} bytes, crafter {} bytes", fs::file_size(nostroPath), fs::file_size(crafterPath));
std::println("(sink {})", sink & 1);
fs::remove_all(dir);
return 0;
}

View file

@ -0,0 +1,97 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// StringRef and BlobRef semantics: equality against string_view, Key,
// std::string, literals and other refs across the inline/heap boundary,
// prefix-equal non-matches, ordering, implicit conversion, and blob element
// alignment for 4- and 8-byte elements.
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 Strings {
std::string a;
std::string b;
std::vector<std::uint32_t> words;
std::vector<double> doubles;
std::vector<std::byte> bytes;
static constexpr std::uint32_t version = 1;
};
std::string_view V(std::string_view s) { return s; }
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-stringref";
fs::remove_all(dir);
DB::Table<Strings> table(dir / "s.cdb");
const std::vector<std::string> values = { "", "abc", "abcd", "abcde", "twelve_chars", "thirteen_char", "a much longer value that lives in the heap region" };
for (const std::string& v : values) table.Append({ v, v, { 1, 2, 3 }, { 1.5, 2.5 }, { std::byte{ 1 } } });
table.Append({ "abcd", "abcX", { 7, 8, 9, 10, 11 }, {}, {} }); // same length, same 3-byte prefix, differs in the 4th byte
table.Append({ "thirteen_char", "thirteen_chaX", {}, { 3.5 }, {} });
for (std::size_t i = 0; i < values.size(); i++) {
const DB::StringRef ref = table[i][&Strings::a];
const std::string& v = values[i];
Check(ref == V(v), std::format("== string_view for '{}'", v));
Check(ref == v, std::format("== std::string for '{}'", v));
Check(ref == DB::Key(v), std::format("== Key for '{}'", v));
Check(ref == table[i][&Strings::b], std::format("== StringRef for '{}'", v));
Check(!(ref != V(v)), std::format("!= false for '{}'", v));
Check(std::string_view(ref) == v, std::format("implicit string_view for '{}'", v));
Check(ref.Length() == v.size(), std::format("Length for '{}'", v));
Check(ref.Empty() == v.empty(), std::format("Empty for '{}'", v));
for (std::size_t j = 0; j < values.size(); j++) {
if (j == i) continue;
Check(!(ref == V(values[j])), std::format("'{}' != '{}'", v, values[j]));
Check(!(ref == DB::Key(values[j])), std::format("'{}' != Key '{}'", v, values[j]));
Check(!(ref == table[j][&Strings::a]), std::format("'{}' != ref '{}'", v, values[j]));
}
}
Check(!(table[7][&Strings::a] == table[7][&Strings::b]), "prefix-equal inline strings differ in the tail");
Check(!(table[8][&Strings::a] == table[8][&Strings::b]), "prefix-equal heap strings differ in the last byte");
Check(table[8][&Strings::a] == V("thirteen_char") && !(table[8][&Strings::a] == V("thirteen_chaX")), "heap compare checks all bytes");
Check((table[1][&Strings::a] <=> V("abd")) == std::strong_ordering::less, "ordering less");
Check((table[1][&Strings::a] <=> V("abb")) == std::strong_ordering::greater, "ordering greater");
Check((table[1][&Strings::a] <=> V("abc")) == std::strong_ordering::equal, "ordering equal");
Check(table[1][&Strings::a] < V("abcd"), "prefix shorter sorts first");
Check(table[6][&Strings::a] < table[1][&Strings::a], "ref <=> ref (space sorts before b)");
Check(table[1][&Strings::a] == "abc", "== literal");
const DB::BlobRef<std::uint32_t> words = table[0][&Strings::words];
const std::span<const std::uint32_t> wordSpan = words;
Check(wordSpan.size() == 3 && wordSpan[0] == 1 && wordSpan[2] == 3, "u32 blob round trip (inline)");
Check(reinterpret_cast<std::uintptr_t>(wordSpan.data()) % alignof(std::uint32_t) == 0, "inline u32 blob is 4-aligned");
const std::span<const std::uint32_t> longWords = table[7][&Strings::words];
Check(longWords.size() == 5 && longWords[4] == 11, "u32 blob round trip (heap)");
Check(reinterpret_cast<std::uintptr_t>(longWords.data()) % alignof(std::uint32_t) == 0, "heap u32 blob is 4-aligned");
const std::span<const double> doubles = table[0][&Strings::doubles];
Check(doubles.size() == 2 && doubles[1] == 2.5, "double blob round trip");
Check(reinterpret_cast<std::uintptr_t>(doubles.data()) % alignof(double) == 0, "double blob is 8-aligned (never inline)");
const std::span<const double> one = table[8][&Strings::doubles];
Check(one.size() == 1 && one[0] == 3.5, "single double blob");
const std::span<const std::byte> bytes = table[0][&Strings::bytes];
Check(bytes.size() == 1 && bytes[0] == std::byte{ 1 }, "byte blob round trip");
Check(table[7][&Strings::bytes].Size() == 0, "empty blob");
const Strings copy = table[7].Materialize();
Check(copy.words == std::vector<std::uint32_t>{ 7, 8, 9, 10, 11 } && copy.doubles.empty(), "Materialize blobs");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

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;
}

View file

@ -0,0 +1,187 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Every built-in member shape and both user formatter shapes round-trip:
// fixed (a class stored as basis points), variable-length inheriting from the
// std::string formatter, optionals of scalars and strings, nested aggregates
// with strings, scalar arrays, byte arrays, FixedString, chrono durations,
// bool, double, std::byte and blobs.
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;
}
}
class Percent {
public:
Percent() = default;
explicit Percent(double value) : basisPoints(static_cast<std::int32_t>(std::lround(value * 100))) {}
double Value() const noexcept { return basisPoints / 100.0; }
std::int32_t BasisPoints() const noexcept { return basisPoints; }
friend bool operator==(const Percent&, const Percent&) = default;
private:
std::int32_t basisPoints = 0;
};
class Tags {
public:
Tags() = default;
explicit Tags(std::vector<std::string> values) : values(std::move(values)) {}
const std::vector<std::string>& Values() const noexcept { return values; }
std::string Joined() const {
std::string out;
for (const std::string& v : values) {
if (!out.empty()) out += ',';
out += v;
}
return out;
}
static Tags Split(std::string_view text) {
std::vector<std::string> values;
for (const auto part : std::views::split(text, ',')) {
if (!part.empty()) values.emplace_back(part.begin(), part.end());
}
return Tags(std::move(values));
}
friend bool operator==(const Tags&, const Tags&) = default;
private:
std::vector<std::string> values;
};
}
template<>
struct Crafter::DB::Formatter<Percent> {
using Stored = std::int32_t;
static Stored Encode(const Percent& value) noexcept { return value.BasisPoints(); }
static Percent Decode(const Stored& stored) noexcept { return Percent(stored / 100.0); }
};
template<>
struct Crafter::DB::Formatter<Tags> : Crafter::DB::Formatter<std::string> {
static Var Encode(const Tags& value, HeapWriter& heap) { return Formatter<std::string>::Encode(value.Joined(), heap); }
static Tags Materialize(StringRef view) { return Tags::Split(view.View()); }
};
namespace {
enum class Currency : std::uint16_t { Euro, Dollar };
struct Money {
std::int64_t minor;
Currency currency;
friend bool operator==(const Money&, const Money&) = default;
};
struct Party {
std::string name;
DB::FixedString<11> bic;
std::optional<std::string> note;
friend bool operator==(const Party&, const Party&) = default;
};
struct Everything {
bool flag;
std::byte raw;
double ratio;
std::chrono::milliseconds delay;
std::chrono::duration<double> seconds;
Percent fee;
Tags tags;
std::optional<std::int32_t> maybe;
std::optional<std::string> maybeText;
Money money;
Party party;
std::array<std::uint16_t, 3> triple;
std::array<char, 4> code;
DB::FixedString<34> iban;
std::vector<std::uint32_t> ids;
static constexpr std::uint32_t version = 7;
friend bool operator==(const Everything&, const Everything&) = default;
};
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-formatters";
fs::remove_all(dir);
const fs::path path = dir / "e.cdb";
const Everything a{ true, std::byte{ 7 }, 0.25, std::chrono::milliseconds(1500), std::chrono::duration<double>(2.5), Percent(12.5), Tags({ "food", "monthly" }), 42, std::string("present"), { -1234, Currency::Dollar }, { "Shop", DB::FixedString<11>("ABNANL2AXXX"), std::string("vip customer with a long note") }, { 1, 2, 3 }, { 'E', 'U', 'R', '\0' }, DB::FixedString<34>("NL91ABNA0417164300"), { 10, 20, 30, 40, 50 } };
const Everything b{ false, std::byte{ 0 }, -1.5, std::chrono::milliseconds(0), std::chrono::duration<double>(0), Percent(0), Tags(std::vector<std::string>{}), std::nullopt, std::nullopt, { 0, Currency::Euro }, { "", DB::FixedString<11>(""), std::nullopt }, { 0, 0, 0 }, { 'U', 'S', 'D', '\0' }, DB::FixedString<34>(""), {} };
{
DB::Table<Everything> table(path);
table.Append(a);
table.Append(b);
const DB::Row<Everything> ra = table[0];
Check(ra[&Everything::flag] == true, "bool");
Check(ra[&Everything::raw] == std::byte{ 7 }, "std::byte");
Check(ra[&Everything::ratio] == 0.25, "double");
Check(ra[&Everything::delay] == std::chrono::milliseconds(1500), "duration");
Check(ra[&Everything::seconds] == std::chrono::duration<double>(2.5), "floating duration");
static_assert(DB::Layout<Everything>::columns[DB::Layout<Everything>::Column(DB::LeafStart<Everything, 4>())].kind == DB::Kind::Float64, "a floating chrono rep keeps its Float kind");
static_assert(DB::Layout<Everything>::columns[DB::Layout<Everything>::Column(DB::LeafStart<Everything, 4>())].paramDen == 1, "but records its period");
Check(ra[&Everything::fee] == Percent(12.5), "fixed user formatter decodes by value");
Check(ra[&Everything::tags] == "food,monthly", "var user formatter view is the raw encoding");
Check(ra.Get<&Everything::maybe>() == std::optional<std::int32_t>(42), "optional scalar engaged");
Check(ra[&Everything::maybeText].has_value() && *ra[&Everything::maybeText] == "present", "optional string engaged (view)");
Check(ra[&Everything::money] == Money{ -1234, Currency::Dollar }, "nested aggregate by value");
const Party party = ra[&Everything::party];
Check(party == a.party, "nested aggregate with strings, FixedString and optional");
Check(ra[&Everything::triple] == std::array<std::uint16_t, 3>{ 1, 2, 3 }, "scalar array");
Check(ra[&Everything::code] == std::array<char, 4>{ 'E', 'U', 'R', '\0' }, "char array");
Check(ra[&Everything::iban].View() == "NL91ABNA0417164300", "FixedString view");
Check(std::span<const std::uint32_t>(ra[&Everything::ids]).size() == 5, "blob");
const DB::Row<Everything> rb = table[1];
Check(!rb[&Everything::maybe].has_value(), "optional scalar empty");
Check(!rb[&Everything::maybeText].has_value(), "optional string empty");
Check(rb[&Everything::party].note == std::nullopt, "nested optional empty");
Check(rb[&Everything::iban].View().empty(), "empty FixedString");
Check(table.Materialize() == std::vector<Everything>{ a, b }, "Materialize round trip of everything");
table.Set<&Everything::maybe>(1, std::optional<std::int32_t>(-9));
Check(rb.Get<&Everything::maybe>() == std::optional<std::int32_t>(-9), "Set an optional");
table.Set<&Everything::fee>(1, Percent(1.0));
Check(rb[&Everything::fee] == Percent(1.0), "Set through a user formatter");
table.Set<&Everything::party>(1, Party{ "Changed", DB::FixedString<11>("RABONL2U"), std::nullopt });
Check(rb[&Everything::party].name == "Changed", "Set a nested aggregate");
const std::string shown = std::format("{}", ra);
Check(shown.find("fee=") != std::string::npos && shown.find("money={minor=-1234, currency=1}") != std::string::npos && shown.find("maybeText=\"present\"") != std::string::npos, std::format("formatter: {}", shown));
}
{
const DB::Table<Everything> table(path, { .readOnly = true });
Check(table[0].Materialize() == a, "row a survives reopen");
const Everything bb = table[1].Materialize();
Check(bb.maybe == -9 && bb.fee == Percent(1.0) && bb.party.name == "Changed", "updated row b survives reopen");
const DB::Inspection info = DB::TableFile::Inspect(path);
bool found = false;
for (std::size_t i = 0; i < info.schema.columns.size(); i++) {
if (info.schema.Name(i) == "party.bic") found = info.schema.columns[i].kind == DB::Kind::FixedBytes && info.schema.columns[i].size == 11;
}
Check(found, "nested column 'party.bic' described as 11 fixed bytes");
}
static_assert(DB::Layout<Everything>::columns[1].offset == 1 || DB::Layout<Everything>::columns[1].size == 1, "1-byte columns follow the flags byte");
static_assert(DB::Layout<Everything>::rowAlign == 8);
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,171 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Pins the on-disk layout of the reference Transaction row: 88 bytes, the
// documented column offsets, the fingerprint recomputed from an independent
// spelling of the canonical hash, and the exact bytes of the 16-byte string
// handle for an empty, an inline and a heap value. A change here is a file
// format change.
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<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;
}
}
enum class Currency : std::uint16_t { Euro };
struct Transaction {
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 = &Transaction::timePoint;
};
using Layout = DB::Layout<Transaction>;
// The documented row: flags at 0, ascending alignment classes after it.
struct ExpectedColumn {
std::string_view name;
DB::Kind kind;
std::uint16_t offset;
std::uint16_t size;
std::uint8_t flags;
std::uint32_t paramNum;
std::uint32_t paramDen;
std::uint32_t variant;
};
constexpr std::uint32_t systemClock = DB::TypeCode<std::chrono::system_clock>();
constexpr std::array<ExpectedColumn, 8> expected = { {
{ "flags", DB::Kind::Flags, 0, 1, 0, 0, 0, 0 },
{ "currency", DB::Kind::UInt16, 2, 2, DB::columnFlagEnum, 0, 0, 0 },
{ "name", DB::Kind::String, 8, 16, 0, 0, 0, 0 },
{ "bic", DB::Kind::String, 24, 16, 0, 0, 0, 0 },
{ "iban", DB::Kind::String, 40, 16, 0, 0, 0, 0 },
{ "description", DB::Kind::String, 56, 16, 0, 0, 0, 0 },
{ "amount", DB::Kind::Int64, 72, 8, 0, 0, 0, 0 },
{ "timePoint", DB::Kind::TimePoint, 80, 8, 0, 1, 1000000, systemClock },
} };
constexpr std::uint16_t orderedColumn = 7; // timePoint
// Independent spelling of the fingerprint, written from the format
// description rather than from Layout's code.
std::uint64_t SpecFingerprint() {
std::uint64_t hash = DB::Fnv1a(DB::fnvOffset, "crafter-db-v1");
hash = DB::Fnv1a(hash, std::uint64_t(88));
hash = DB::Fnv1a(hash, std::uint64_t(8));
hash = DB::Fnv1a(hash, std::uint64_t(orderedColumn));
for (const ExpectedColumn& c : expected) {
hash = DB::Fnv1a(hash, c.name);
hash = DB::Fnv1a(hash, static_cast<std::uint64_t>(c.kind));
hash = DB::Fnv1a(hash, c.offset);
hash = DB::Fnv1a(hash, c.size);
hash = DB::Fnv1a(hash, c.flags);
hash = DB::Fnv1a(hash, c.paramNum);
hash = DB::Fnv1a(hash, c.paramDen);
hash = DB::Fnv1a(hash, c.variant);
}
return hash;
}
}
int main() {
static_assert(Layout::rowSize == 88);
static_assert(Layout::rowAlign == 8);
static_assert(Layout::columnCount == 8);
for (std::size_t i = 0; i < expected.size(); i++) {
const DB::ColumnInfo& column = Layout::columns[i];
CheckEqual<std::string_view>(column.name.View(), expected[i].name, std::format("column {} name", i));
Check(column.kind == expected[i].kind, std::format("column {} kind", i));
CheckEqual<std::uint16_t>(column.offset, expected[i].offset, std::format("column {} offset", i));
CheckEqual<std::uint16_t>(column.size, expected[i].size, std::format("column {} size", i));
CheckEqual<std::uint8_t>(column.flags, expected[i].flags, std::format("column {} flags", i));
CheckEqual<std::uint32_t>(column.paramNum, expected[i].paramNum, std::format("column {} paramNum", i));
CheckEqual<std::uint32_t>(column.paramDen, expected[i].paramDen, std::format("column {} paramDen", i));
CheckEqual<std::uint32_t>(column.variant, expected[i].variant, std::format("column {} variant", i));
}
CheckEqual<std::uint16_t>(Layout::orderedColumn, orderedColumn, "ordered column index");
CheckEqual<std::uint64_t>(Layout::fingerprint, SpecFingerprint(), "fingerprint matches the specification");
CheckEqual<std::uint64_t>(DB::MakeSchemaInfo<Transaction>().Fingerprint(Layout::orderedColumn), Layout::fingerprint, "SchemaInfo fingerprint matches Layout");
// Descriptor blob round trip.
const DB::SchemaInfo info = DB::MakeSchemaInfo<Transaction>();
const std::vector<std::byte> blob = info.Serialize();
DB::SchemaInfo parsed;
Check(DB::SchemaInfo::Parse(blob, info.rowAlign, parsed), "schema blob parses");
CheckEqual<std::size_t>(parsed.columns.size(), 8, "parsed column count");
CheckEqual<std::string_view>(parsed.Name(4), "iban", "parsed column name");
CheckEqual<std::uint64_t>(parsed.Fingerprint(Layout::orderedColumn), Layout::fingerprint, "parsed fingerprint");
// Raw handle bytes.
const fs::path dir = fs::temp_directory_path() / "crafter-db-layout";
fs::remove_all(dir);
{
DB::Table<Transaction> table(dir / "t.cdb");
table.Append({ "", "ABNANL2A", "NL91ABNA0417164300", "", Currency::Euro, 1, TimePoint(std::chrono::microseconds(1)) });
const std::byte* row = table[0].Bytes();
std::uint8_t flags;
std::memcpy(&flags, row, 1);
CheckEqual<std::uint8_t>(flags, 0, "live row has flags 0");
DB::Var empty;
std::memcpy(&empty, row + 8, 16);
Check(empty.length == 0 && empty.prefix == 0 && empty.tailOrOffset == 0, "empty string is an all-zero handle");
DB::Var bic;
std::memcpy(&bic, row + 24, 16);
CheckEqual<std::uint32_t>(bic.length, 8, "inline handle length");
std::array<char, 12> inlineBytes{};
std::memcpy(inlineBytes.data(), row + 24 + 4, 12);
CheckEqual<std::string>(std::string(inlineBytes.data(), 8), "ABNANL2A", "inline handle bytes 4..12 hold the value");
Check(inlineBytes[8] == 0 && inlineBytes[11] == 0, "inline handle is zero padded");
DB::Var iban;
std::memcpy(&iban, row + 40, 16);
CheckEqual<std::uint32_t>(iban.length, 18, "heap handle length");
std::array<char, 4> prefix{};
std::memcpy(prefix.data(), &iban.prefix, 4);
CheckEqual<std::string>(std::string(prefix.data(), 4), "NL91", "heap handle prefix");
const std::span<const std::byte> heapBytes = table.File().HeapView().Read(iban.tailOrOffset, iban.length);
CheckEqual<std::string>(std::string(reinterpret_cast<const char*>(heapBytes.data()), heapBytes.size()), "NL91ABNA0417164300", "heap holds the full value, prefix included");
CheckEqual<std::uint64_t>(iban.tailOrOffset, 0, "first heap value sits at heap offset 0");
CheckEqual<std::uint64_t>(table.Stats().heapUsed, 18, "heap used equals the one out-of-line value");
std::int64_t micros;
std::memcpy(&micros, row + 80, 8);
CheckEqual<std::int64_t>(micros, 1, "timePoint stored as its microsecond rep");
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,102 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Grows a table through many extents while holding Row views and
// string_views taken before the growth. Guards the fixed-address mapping
// promise: appends never move existing rows or heap bytes.
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<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 Event {
std::string label;
std::string payload;
std::uint64_t sequence;
static constexpr std::uint32_t version = 1;
static constexpr auto orderedBy = &Event::sequence;
};
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-growth";
fs::remove_all(dir);
const fs::path path = dir / "events.cdb";
constexpr std::uint64_t count = 300'000;
{
DB::Table<Event> table(path);
table.Emplace(std::string_view("first"), std::string_view("payload of the very first row, long enough for the heap"), std::uint64_t(0));
const DB::Row<Event> first = table[0];
const std::string_view firstPayload = first[&Event::payload];
const std::byte* firstBytes = first.Bytes();
const char* firstHeapPointer = firstPayload.data();
std::vector<Event> batch;
batch.reserve(1000);
for (std::uint64_t i = 1; i < count; i++) {
batch.push_back({ std::format("row-{}", i), std::format("payload-{}-{}", i, std::string(i % 40, 'p')), i });
if (batch.size() == 1000) {
table.Append(batch);
batch.clear();
}
}
if (!batch.empty()) table.Append(batch);
CheckEqual<std::uint64_t>(table.Size(), count, "all rows appended");
Check(first.Bytes() == firstBytes, "row pointer unchanged after growth");
Check(firstPayload.data() == firstHeapPointer, "heap pointer unchanged after growth");
Check(firstPayload == "payload of the very first row, long enough for the heap", "early string_view still reads correctly");
Check(first[&Event::label] == "first", "early row view still decodes");
Check(table[count - 1][&Event::label] == std::format("row-{}", count - 1), "last row readable");
Check(table[123456][&Event::payload] == std::format("payload-{}-{}", 123456, std::string(123456 % 40, 'p')), "middle row readable");
const DB::TableStats stats = table.Stats();
Check(stats.fileLength > count * DB::Layout<Event>::rowSize, "file holds the rows");
table.Flush();
const DB::Inspection info = DB::TableFile::Inspect(path);
Check(info.commit.extentCount[0] >= 5, std::format("rows region grew through several extents ({})", info.commit.extentCount[0]));
Check(info.commit.extentCount[1] >= 5, std::format("heap region grew through several extents ({})", info.commit.extentCount[1]));
CheckEqual<std::uint64_t>(info.commit.rowCount, count, "committed row count");
Check(info.commit.fileLength == fs::file_size(path), "committed length equals file size after flush");
}
{
const DB::Table<Event> table(path, { .readOnly = true });
CheckEqual<std::uint64_t>(table.Size(), count, "rows survive reopen");
std::uint64_t expected = 0;
bool ordered = true;
for (DB::Row<Event> row : table) {
if (row[&Event::sequence] != expected) ordered = false;
expected++;
}
Check(ordered && expected == count, "sequence intact after reopen");
CheckEqual<std::uint64_t>(table.LowerBound<&Event::sequence>(150'000), 150'000, "binary search over a multi-extent table");
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,98 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// One writer per file: a second writer in another process or in this one
// gets LockedError, readers are refused while a writer holds the file,
// readers coexist with each other, and a read-only table refuses writes.
#include <sys/wait.h>
#include <unistd.h>
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 Item {
std::string name;
static constexpr std::uint32_t version = 1;
};
// Returns the child's exit code: 0 = opened fine, 3 = LockedError, 4 = other.
int ChildOpens(const fs::path& path, bool readOnly) {
const pid_t pid = fork();
if (pid == 0) {
try {
DB::Table<Item> table(path, { .mode = DB::OpenMode::OpenExisting, .readOnly = readOnly });
_exit(0);
} catch (const DB::LockedError&) {
_exit(3);
} catch (...) {
_exit(4);
}
}
int status = 0;
waitpid(pid, &status, 0);
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-lock";
fs::remove_all(dir);
const fs::path path = dir / "items.cdb";
{
DB::Table<Item> writer(path);
writer.Append({ "one" });
Check(ChildOpens(path, false) == 3, "second writer in another process gets LockedError");
Check(ChildOpens(path, true) == 3, "reader in another process is refused while a writer holds the file");
bool threw = false;
try {
DB::Table<Item> second(path);
} catch (const DB::LockedError&) {
threw = true;
}
Check(threw, "second Table on the same file in this process gets LockedError");
}
{
DB::Table<Item> reader(path, { .readOnly = true });
Check(ChildOpens(path, true) == 0, "readers coexist across processes");
Check(ChildOpens(path, false) == 3, "writer is refused while a reader holds the file");
bool threw = false;
try {
reader.Append({ "two" });
} catch (const DB::ReadOnlyError&) {
threw = true;
}
Check(threw, "Append on a read-only table throws ReadOnlyError");
threw = false;
try {
reader.Erase(0);
} catch (const DB::ReadOnlyError&) {
threw = true;
}
Check(threw, "Erase on a read-only table throws ReadOnlyError");
Check(reader.Size() == 1 && reader[0][&Item::name] == "one", "read-only table reads");
}
Check(ChildOpens(path, false) == 0, "writer opens once everyone is gone");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,236 @@
// 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<T>() 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<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;
}
}
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<TimePoint::duration>(std::chrono::seconds(seconds)));
}
void WriteV1(const fs::path& path) {
DB::Table<TransactionV1> 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<TransactionV1> same(path);
CheckEqual<std::uint64_t>(same.Size(), 3, "V1 reopens as V1");
}
{
bool threw = false;
try {
DB::Table<Transaction> 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<Transaction> refused(path, { .migrate = false });
} catch (const DB::MigrationError&) {
threw = true;
}
Check(threw, "migrate = false throws MigrationError");
CheckEqual<std::uint32_t>(DB::PeekVersion(path), 1, "file untouched by refused migrations");
}
{
DB::Table<Transaction> table(path);
CheckEqual<std::uint64_t>(table.Size(), 3, "live rows carried over (tombstone dropped)");
CheckEqual<std::uint64_t>(table.SlotCount(), 3, "migration compacts");
const std::vector<Transaction> rows = table.Materialize();
CheckEqual<std::string>(rows[0].memo, "groceries", "description -> memo");
Check(rows[1].money == Money{ 300000, Currency::Dollar }, "amount + currency -> money");
CheckEqual<std::string>(rows[1].reference, "", "new V2 column value-initialised");
CheckEqual<std::string>(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<std::uint32_t>(info.header.schemaVersion, 3, "file now at version 3");
CheckEqual<std::uint32_t>(info.header.migratedFromVersion, 1, "migratedFromVersion recorded");
Check(fs::exists(DB::TableFile::BackupPath(path, 1)), "backup of the V1 file kept");
CheckEqual<std::uint32_t>(DB::PeekVersion(DB::TableFile::BackupPath(path, 1)), 1, "backup is the V1 file");
table.Append({ "After", "", "", "", "ref", { 5, Currency::Euro }, At(5) });
CheckEqual<std::uint64_t>(table.Size(), 4, "append after migration");
}
{
// A V2 file skips the by-name step.
const fs::path path2 = dir / "tx2.cdb";
{
DB::Table<TransactionV2> v2(path2);
v2.Append({ "A", "B", "C", "D", "REF-1", Currency::Euro, 7, At(1) });
}
DB::Table<Transaction> table(path2, { .backupBeforeMigrate = false });
CheckEqual<std::uint64_t>(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<TransactionV2>(path3);
CheckEqual<std::uint32_t>(DB::PeekVersion(path3), 2, "explicit Migrate<V2>");
const DB::Table<TransactionV2> v2(path3, { .readOnly = true });
CheckEqual<std::uint64_t>(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<EventV1> v1(events);
v1.Append({ "late", 20 });
v1.Append({ "early", 10 });
v1.Append({ "middle", 15 });
v1.Append({ "also early", 10 });
}
DB::Table<EventV2> v2(events, { .backupBeforeMigrate = false });
CheckEqual<std::uint64_t>(v2.Size(), 4, "all rows migrated");
std::vector<std::int64_t> order;
for (DB::Row<EventV2> row : v2) order.push_back(row[&EventV2::when]);
CheckEqual(order, std::vector<std::int64_t>{ 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<TransactionV1> 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;
}

171
tests/ShouldQuery/main.cpp Normal file
View file

@ -0,0 +1,171 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Queries: lambda filters with Key and string_view, column expressions,
// binary search on the ordered column (Range/From/After/Until/Before,
// Lower/UpperBound at the boundaries, equal keys, tombstoned slots), chaining
// Range().Where(), bound extraction from a combined expression, ranges
// composition, and the OrderError checks on Update/Set.
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<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;
}
}
enum class Currency : std::uint16_t { Euro };
struct Transaction {
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 = &Transaction::timePoint;
};
inline constexpr DB::Column<&Transaction::iban> iban{};
inline constexpr DB::Column<&Transaction::description> description{};
inline constexpr DB::Column<&Transaction::amount> amount{};
inline constexpr DB::Column<&Transaction::timePoint> timePoint{};
TimePoint At(std::int64_t seconds) {
return TimePoint(std::chrono::duration_cast<TimePoint::duration>(std::chrono::seconds(seconds)));
}
std::vector<std::uint64_t> Ids(auto&& query) {
std::vector<std::uint64_t> ids;
for (auto row : query) ids.push_back(row.Id());
return ids;
}
template<class Query>
std::vector<std::uint64_t> IdsOf(Query query) {
std::vector<std::uint64_t> ids;
for (auto row : query) ids.push_back(row.Id());
return ids;
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-query";
fs::remove_all(dir);
DB::Table<Transaction> table(dir / "q.cdb");
const std::string_view ibanA = "NL91ABNA0417164300";
const std::string_view ibanB = "NL91ABNA0417164301"; // same length, same 4-byte prefix
const std::string_view ibanC = "DE89370400440532013000";
// id key
table.Append({ "a", "ABNANL2A", std::string(ibanA), "rent", Currency::Euro, -100000, At(10) }); // 0
table.Append({ "b", "ABNANL2A", std::string(ibanB), "rent", Currency::Euro, -200, At(20) }); // 1
table.Append({ "c", "RABONL2U", std::string(ibanC), "coffee", Currency::Euro, -350, At(20) }); // 2 equal key
table.Append({ "d", "RABONL2U", std::string(ibanA), "salary", Currency::Euro, 300000, At(30) }); // 3
table.Append({ "e", "INGBNL2A", std::string(ibanC), "rent ", Currency::Euro, -100000, At(40) }); // 4 trailing space: same length as "rent" + 1
table.Append({ "f", "INGBNL2A", std::string(ibanA), "RENT", Currency::Euro, -5, At(50) }); // 5
// --- lambda filters ---------------------------------------------------
const DB::Key keyA(ibanA);
CheckEqual(IdsOf(table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == keyA; })), std::vector<std::uint64_t>{ 0, 3, 5 }, "lambda with Key");
CheckEqual(IdsOf(table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == ibanB; })), std::vector<std::uint64_t>{ 1 }, "lambda with string_view, prefix-equal neighbour rejected");
CheckEqual(IdsOf(table.Where([](DB::Row<Transaction> r) { return r[&Transaction::description] == "rent"; })), std::vector<std::uint64_t>{ 0, 1 }, "description equality is exact (case and length)");
CheckEqual(table.Where([](DB::Row<Transaction> r) { return r[&Transaction::amount] < 0; }).Count(), std::uint64_t(5), "Count");
Check(table.Where(amount > 0).First().has_value() && table.Where(amount > 0).First()->Id() == 3, "First");
Check(!table.Where(amount > 1'000'000).Any(), "Any on no match");
// --- column expressions -------------------------------------------------
CheckEqual(IdsOf(table.Where(iban == ibanA)), std::vector<std::uint64_t>{ 0, 3, 5 }, "expression ==");
CheckEqual(IdsOf(table.Where(iban != ibanA)), std::vector<std::uint64_t>{ 1, 2, 4 }, "expression !=");
CheckEqual(IdsOf(table.Where(iban == ibanA && amount > 0)), std::vector<std::uint64_t>{ 3 }, "expression &&");
CheckEqual(IdsOf(table.Where(description == "coffee" || description == "salary")), std::vector<std::uint64_t>{ 2, 3 }, "expression ||");
CheckEqual(IdsOf(table.Where(!(amount < 0))), std::vector<std::uint64_t>{ 3 }, "expression !");
CheckEqual(IdsOf(table.Where(description < "d")), std::vector<std::uint64_t>{ 2, 5 }, "string ordering compare ('RENT' and 'coffee' sort before 'd')");
// --- ordered column -----------------------------------------------------
CheckEqual<std::uint64_t>(table.LowerBound<&Transaction::timePoint>(At(20)), 1, "LowerBound on equal keys");
CheckEqual<std::uint64_t>(table.UpperBound<&Transaction::timePoint>(At(20)), 3, "UpperBound on equal keys");
CheckEqual<std::uint64_t>(table.LowerBound<&Transaction::timePoint>(At(5)), 0, "LowerBound below all");
CheckEqual<std::uint64_t>(table.LowerBound<&Transaction::timePoint>(At(55)), 6, "LowerBound above all");
CheckEqual(IdsOf(table.Range<&Transaction::timePoint>(At(20), At(40))), std::vector<std::uint64_t>{ 1, 2, 3, 4 }, "Range inclusive both ends");
CheckEqual(IdsOf(table.Range<&Transaction::timePoint>(At(21), At(29))), std::vector<std::uint64_t>{}, "Range with no rows");
CheckEqual(IdsOf(table.Range<&Transaction::timePoint>(At(40), At(20))), std::vector<std::uint64_t>{}, "Range with inverted bounds is empty");
CheckEqual(IdsOf(table.From<&Transaction::timePoint>(At(30))), std::vector<std::uint64_t>{ 3, 4, 5 }, "From");
CheckEqual(IdsOf(table.After<&Transaction::timePoint>(At(30))), std::vector<std::uint64_t>{ 4, 5 }, "After");
CheckEqual(IdsOf(table.Until<&Transaction::timePoint>(At(20))), std::vector<std::uint64_t>{ 0, 1, 2 }, "Until");
CheckEqual(IdsOf(table.Before<&Transaction::timePoint>(At(20))), std::vector<std::uint64_t>{ 0 }, "Before");
// --- range + property ---------------------------------------------------
const std::vector<std::uint64_t> chained = IdsOf(table.Range<&Transaction::timePoint>(At(20), At(50)).Where(iban == ibanA));
CheckEqual(chained, std::vector<std::uint64_t>{ 3, 5 }, "Range().Where(expression)");
CheckEqual(IdsOf(table.Range<&Transaction::timePoint>(At(20), At(50)).Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == keyA; })), chained, "Range().Where(lambda)");
CheckEqual(IdsOf(table.Where(timePoint >= At(20) && timePoint <= At(50) && iban == ibanA)), chained, "combined expression extracts bounds");
CheckEqual(IdsOf(table.Where(timePoint > At(20) && timePoint < At(50))), std::vector<std::uint64_t>{ 3, 4 }, "exclusive bounds");
CheckEqual(IdsOf(table.Where(timePoint == At(20))), std::vector<std::uint64_t>{ 1, 2 }, "equality bound");
CheckEqual(IdsOf(table.Where(timePoint == At(20) || iban == ibanA)), std::vector<std::uint64_t>{ 0, 1, 2, 3, 5 }, "|| disables bound extraction but stays correct");
// --- ranges composition -------------------------------------------------
std::uint64_t taken = 0;
for (DB::Row<Transaction> row : table | std::views::filter([](DB::Row<Transaction> r) { return r[&Transaction::amount] < 0; }) | std::views::take(2)) taken += row.Id() + 1;
CheckEqual<std::uint64_t>(taken, 1 + 2, "views::filter | views::take over the table");
const std::int64_t total = std::ranges::fold_left(table.Select<&Transaction::amount>(), std::int64_t(0), std::plus{});
CheckEqual<std::int64_t>(total, -100000 - 200 - 350 + 300000 - 100000 - 5, "Select + fold_left");
CheckEqual<std::size_t>(table.Slots().size(), 6, "Slots size");
Check(std::ranges::lower_bound(table.Slots(), At(30), {}, [](DB::Row<Transaction> r) { return r[&Transaction::timePoint]; }) - table.Slots().begin() == 3, "std::ranges::lower_bound over Slots()");
// --- tombstones in the search -------------------------------------------
table.Erase(2);
CheckEqual(IdsOf(table.Range<&Transaction::timePoint>(At(20), At(20))), std::vector<std::uint64_t>{ 1 }, "Range skips tombstones but still finds neighbours");
CheckEqual<std::uint64_t>(table.UpperBound<&Transaction::timePoint>(At(20)), 3, "UpperBound unchanged by a tombstone (keys survive)");
CheckEqual(IdsOf(table.Where(timePoint == At(20))), std::vector<std::uint64_t>{ 1 }, "bound extraction with a tombstone");
// --- order checks on Update/Set -----------------------------------------
bool threw = false;
try {
table.Update(3, { "d", "RABONL2U", std::string(ibanA), "salary", Currency::Euro, 300000, At(15) }); // would sort before row 1/2
} catch (const DB::OrderError&) {
threw = true;
}
Check(threw, "Update that breaks the order throws");
threw = false;
try {
table.Set<&Transaction::timePoint>(3, At(45)); // would sort after row 4
} catch (const DB::OrderError&) {
threw = true;
}
Check(threw, "Set that breaks the order throws");
table.Set<&Transaction::timePoint>(3, At(35));
Check(table[3][&Transaction::timePoint] == At(35), "Set within the neighbours succeeds");
table.Set<&Transaction::description>(3, "wage");
Check(table[3][&Transaction::description] == "wage", "Set a string column");
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, 22, "short strings leave no heap garbage (only the erased row's 22-byte iban is garbage)");
table.Set<&Transaction::description>(3, "a description longer than twelve");
Check(table[3][&Transaction::description] == "a description longer than twelve", "Set a long string column");
table.Set<&Transaction::description>(3, "short");
CheckEqual<std::uint64_t>(table.Stats().heapGarbage, 22 + 32, "replacing a heap value orphans its bytes");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Readers scanning (with tombstones present, so the per-row flag test is on)
// while one writer appends and erases. Every row a scan yields must be a
// published, live-at-some-point row with increasing ids, and nothing may
// read past the published slots. Guards the sentinel-based iteration and the
// tombstone flag against the Append race.
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;
}
}
// 16-byte rows: a power-of-two size, so a full extent ends exactly on a
// row boundary and the slot after the last published one can be the
// first byte of the unmapped reservation.
struct Tick {
std::uint64_t sequence;
std::uint64_t value;
static constexpr std::uint32_t version = 1;
static constexpr auto orderedBy = &Tick::sequence;
};
static_assert(DB::Layout<Tick>::rowSize == 24 || DB::Layout<Tick>::rowSize == 32);
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-concurrent";
fs::remove_all(dir);
DB::Table<Tick> table(dir / "ticks.cdb");
table.Append({ 0, 0 });
table.Append({ 1, 1 });
table.Erase(0); // a tombstone from the start: scans run with the flag test
std::atomic<bool> stop{ false };
std::atomic<std::uint64_t> scans{ 0 };
std::atomic<std::uint64_t> badOrder{ 0 };
std::atomic<std::uint64_t> badId{ 0 };
std::atomic<std::uint64_t> sawErased{ 0 };
const auto reader = [&] {
while (!stop.load(std::memory_order_relaxed)) {
std::uint64_t last = 0;
bool first = true;
const std::uint64_t slots = table.SlotCount();
for (DB::Row<Tick> row : table) {
const std::uint64_t id = row.Id();
if (!first && id <= last) badOrder.fetch_add(1, std::memory_order_relaxed);
if (id >= table.SlotCount()) badId.fetch_add(1, std::memory_order_relaxed);
if (row[&Tick::sequence] != id) badId.fetch_add(1, std::memory_order_relaxed);
first = false;
last = id;
}
if (slots > 2 && table.Rows(0, 1).Any()) sawErased.fetch_add(1, std::memory_order_relaxed);
scans.fetch_add(1, std::memory_order_relaxed);
}
};
std::vector<std::jthread> readers;
for (std::int32_t i = 0; i < 4; i++) readers.emplace_back(reader);
std::vector<Tick> batch;
for (std::uint64_t sequence = 2; sequence < 400'000; sequence++) {
batch.push_back({ sequence, sequence });
if (batch.size() == 64) {
table.Append(batch);
batch.clear();
if (sequence % 1024 == 1) table.Erase(sequence - 500);
}
}
stop.store(true);
readers.clear();
Check(scans.load() > 0, "readers ran");
Check(badOrder.load() == 0, std::format("ids strictly increase within a scan ({} violations)", badOrder.load()));
Check(badId.load() == 0, std::format("every yielded row is a published row ({} violations)", badId.load()));
Check(sawErased.load() == 0, std::format("the erased first row never shows up ({} times)", sawErased.load()));
Check(table.Verify().Ok(), "table verifies after the run");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,213 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Crash safety of the commit protocol. A child process appends without
// flushing and dies: the parent must see exactly the committed rows. A
// damaged newer commit slot falls back to the older one. Uncommitted growth
// is truncated on the next writable open. Tombstones written before an
// unclean exit are recounted.
#include <sys/wait.h>
#include <unistd.h>
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 Entry {
std::string text;
std::uint64_t n;
static constexpr std::uint32_t version = 1;
};
// Runs `work` in a child. `work` must end with Die() while its table is
// still in scope, so no destructor (and no Flush) ever runs.
[[noreturn]] void Die() { _exit(0); }
void InChildWithoutCleanup(const std::function<void()>& work) {
const pid_t pid = fork();
if (pid == 0) {
try {
work();
} catch (const std::exception& error) {
std::println("child failed: {}", error.what());
_exit(2);
}
_exit(5); // work() returned instead of dying: the test is wrong
}
int status = 0;
waitpid(pid, &status, 0);
Check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "child ran to its exit point");
}
void Patch(const fs::path& path, std::uint64_t offset, std::byte value) {
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*>(&value), 1);
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-recover";
fs::remove_all(dir);
const fs::path path = dir / "log.cdb";
// Committed rows survive, unflushed rows vanish, the table stays valid.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
for (std::uint64_t i = 0; i < 100; i++) table.Append({ std::format("committed {}", i), i });
table.Flush();
for (std::uint64_t i = 100; i < 150; i++) table.Append({ std::format("lost {}", i), i });
Die(); // no Flush, no destructor
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 100, "only flushed rows are visible after a crash");
Check(table.Back()[&Entry::text] == "committed 99", "last committed row intact");
table.Append({ "after crash", 100 });
Check(table[100][&Entry::text] == "after crash", "slot after the committed rows is reusable");
table.Flush();
}
// EveryAppend durability: nothing is lost.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path, { .durability = DB::Durability::EveryAppend });
table.Append({ "durable", 101 });
Die();
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 102, "EveryAppend row survived a crash");
}
// Uncommitted growth is truncated on open.
std::uint64_t committedLength = 0;
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Flush();
committedLength = fs::file_size(path);
for (std::uint64_t i = 0; i < 2000; i++) table.Append({ std::string(100, 'g'), i }); // forces new extents
Die();
});
{
const DB::Inspection info = DB::TableFile::Inspect(path);
committedLength = info.commit.fileLength;
Check(fs::file_size(path) > committedLength, "uncommitted extents extend the file before recovery");
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(fs::file_size(path), committedLength, "writable open truncates uncommitted growth");
CheckEqual<std::uint64_t>(table.Size(), 102, "rows unchanged");
}
// Tombstones written before an unclean exit are recounted.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Erase(0);
table.Erase(1);
Die();
});
{
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.Size(), 100, "live count recounted after unclean exit");
std::uint64_t seen = 0;
for ([[maybe_unused]] DB::Row<Entry> row : table) seen++;
CheckEqual<std::uint64_t>(seen, 100, "iteration honours recovered tombstones");
table.Compact();
table.Flush();
}
// An Update whose heap bytes were written but whose process died before
// any Flush: the row must read as either the old or the new value, never
// point at uncommitted heap bytes.
InChildWithoutCleanup([&] {
DB::Table<Entry> table(path);
table.Flush();
table.Update(5, { std::string(300, 'u'), 5 }); // long value -> heap
table.Set<&Entry::text>(6, std::string(200, 's'));
Die();
});
{
DB::Table<Entry> table(path);
const std::string five(table[5][&Entry::text]);
Check(five == std::string(300, 'u') || five == "committed 5", std::format("updated row is consistent after a crash (got {} bytes)", five.size()));
const std::string six(table[6][&Entry::text]);
Check(six == std::string(200, 's') || six == "committed 6", "Set row is consistent after a crash");
Check(table.Verify().Ok(), "no dangling heap references after a crashed Update");
table.Append({ "after update crash", 7 });
Check(table[5][&Entry::text] == five, "later appends do not change the updated row's text");
table.Flush();
}
// A forked child that destroys its inherited Table must not write a
// commit record: its counters are the parent's at fork time.
{
DB::Table<Entry> table(path);
table.Append({ "parent row", 8 });
table.Flush();
const DB::TableStats before = table.Stats();
table.Erase(2); // tombstone after the flush
const pid_t pid = fork();
if (pid == 0) {
// normal destruction path in the child
bool threw = false;
try {
table.Append({ "child", 9 });
} catch (const DB::Error&) {
threw = true;
}
if (!threw) _exit(6);
const std::string_view text = table[0][&Entry::text]; // reads work on the shared mapping
if (text.size() == 0) _exit(7);
table.~Table();
_exit(0);
}
int status = 0;
waitpid(pid, &status, 0);
Check(WIFEXITED(status) && WEXITSTATUS(status) == 0, std::format("child refused to write and destroyed its table (status {})", WIFEXITED(status) ? WEXITSTATUS(status) : -1));
const DB::Inspection info = DB::TableFile::Inspect(path);
CheckEqual<std::uint64_t>(info.commit.commitSeq, before.commitSeq, "child wrote no commit record");
CheckEqual<std::uint64_t>(table.Size(), before.liveCount - 1, "parent still sees its tombstone");
table.Flush();
const DB::Inspection after = DB::TableFile::Inspect(path);
CheckEqual<std::uint64_t>(after.commit.liveCount, before.liveCount - 1, "parent's commit carries the tombstone");
}
// A damaged newer commit slot falls back to the older one.
{
{
DB::Table<Entry> table(path);
table.Append({ "slot A", 1 });
table.Flush(); // commit N: rows r+1
table.Append({ "slot B", 2 }); // the destructor's clean close writes commit N+1: rows r+2
}
const DB::Inspection before = DB::TableFile::Inspect(path);
const std::size_t newer = before.header.commits[0].commitSeq > before.header.commits[1].commitSeq ? 0 : 1;
Patch(path, 3072 + newer * 512 + 8, std::byte{ 0xEE }); // corrupt rowCount of the newest slot -> CRC fails
DB::Table<Entry> table(path);
CheckEqual<std::uint64_t>(table.SlotCount(), before.commit.rowCount - 1, "fell back to the previous commit");
Check(table.Back()[&Entry::text] == "slot A", "the row of the lost commit is gone");
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

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;
}

View file

@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Append rows of every string length class (empty, inline, heap, huge,
// UTF-8), read them back through views and through Materialize, reopen the
// file and read them again. Guards the encode/decode symmetry and the file
// round trip that everything else builds on.
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<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;
}
}
enum class Currency : std::uint16_t { Euro, Dollar };
struct Transaction {
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 = &Transaction::timePoint;
friend bool operator==(const Transaction&, const Transaction&) = default;
};
TimePoint At(std::int64_t seconds) {
return TimePoint(std::chrono::duration_cast<TimePoint::duration>(std::chrono::seconds(seconds)));
}
std::vector<Transaction> Sample() {
std::vector<Transaction> rows;
rows.push_back({ "Albert Heijn", "ABNANL2A", "NL91ABNA0417164300", "groceries", Currency::Euro, -2345, At(100) });
rows.push_back({ "", "", "", "", Currency::Euro, 0, At(100) }); // all empty, equal key allowed
rows.push_back({ "Twelve chars", "ABCDEFGHIJKL", "ABCDEFGHIJKLM", "exactly twelve", Currency::Dollar, 1, At(101) }); // 12 inline, 13 heap
rows.push_back({ "Café Münster", "RABONL2U", "DE89370400440532013000", std::string(100 * 1024, 'x'), Currency::Euro, 99999999, At(102) });
rows.push_back({ "a", "b", "c", "d", Currency::Euro, -1, At(103) });
return rows;
}
}
int main() {
const fs::path dir = fs::temp_directory_path() / "crafter-db-roundtrip";
fs::remove_all(dir);
const fs::path path = dir / "sub" / "main.cdb";
const std::vector<Transaction> sample = Sample();
{
DB::Table<Transaction> table(path);
Check(table.Empty(), "new table is empty");
CheckEqual<std::uint64_t>(table.Size(), 0, "new table size");
Check(fs::exists(path), "table file created");
Check((fs::status(path).permissions() & fs::perms::others_all) == fs::perms::none, "file is private");
Check((fs::status(dir / "sub").permissions() & fs::perms::group_all) == fs::perms::none, "directory is private");
for (const Transaction& row : sample) table.Append(row);
CheckEqual<std::uint64_t>(table.Size(), sample.size(), "size after append");
CheckEqual<std::uint64_t>(table.SlotCount(), sample.size(), "slots after append");
const DB::Row<Transaction> last = table.Back();
Check(last[&Transaction::timePoint] == At(103), "Back() timePoint via member pointer");
Check(last.Get<&Transaction::amount>() == -1, "Back() amount via Get<>");
Check(last[&Transaction::iban] == "c", "Back() iban view compares to literal");
CheckEqual<std::uint64_t>(last.Id(), 4, "Back() id");
const DB::Row<Transaction> second = table[2];
Check(second[&Transaction::bic] == std::string_view("ABCDEFGHIJKL"), "12-char string inline round trip");
Check(second[&Transaction::iban] == std::string("ABCDEFGHIJKLM"), "13-char string heap round trip");
Check(second[&Transaction::iban] != std::string_view("ABCDEFGHIJKLX"), "same-length prefix-equal non-match");
Check(!(second[&Transaction::iban] == std::string_view("ABCDEFGHIJKL")), "shorter prefix-equal non-match");
Check(second[&Transaction::currency] == Currency::Dollar, "enum round trip");
CheckEqual<std::string>(std::string(second[&Transaction::description].View()), "exactly twelve", "StringRef::View");
const DB::Row<Transaction> empty = table[1];
Check(empty[&Transaction::name].Empty(), "empty string is empty");
Check(empty[&Transaction::name] == "", "empty string equals empty literal");
const DB::Row<Transaction> huge = table[3];
CheckEqual<std::uint32_t>(huge[&Transaction::description].Length(), 100 * 1024, "huge string length");
Check(huge[&Transaction::description] == sample[3].description, "huge string equals");
Check(huge[&Transaction::name] == "Café Münster", "utf-8 inline/heap string");
const std::vector<Transaction> all = table.Materialize();
Check(all == sample, "Materialize equals input");
CheckEqual<std::string>(std::string(DB::TypeName<Transaction>()).substr(0, 0), "", "noop");
const DB::RowId id = table.Emplace(std::string_view("Emplaced"), "BICBICBI", std::string("NL00EMPL0000000001"), std::string_view("via Emplace"), Currency::Euro, std::int64_t(42), At(104));
CheckEqual<std::uint64_t>(id, 5, "Emplace returns the new slot");
Check(table[id][&Transaction::name] == "Emplaced", "Emplace string_view argument");
Check(table[id][&Transaction::iban] == "NL00EMPL0000000001", "Emplace std::string argument");
Check(table[id][&Transaction::amount] == 42, "Emplace scalar argument");
const Transaction materialized = static_cast<Transaction>(table[id]);
CheckEqual<std::string>(materialized.description, "via Emplace", "explicit conversion to T");
const std::string described = std::format("{}", table[4]);
Check(described.find("name=\"a\"") != std::string::npos && described.find("amount=-1") != std::string::npos, std::format("row formatter output: {}", described));
std::uint64_t seen = 0;
for (DB::Row<Transaction> row : table) seen += row.Id() == seen ? 1 : 0;
CheckEqual<std::uint64_t>(seen, 6, "range-for visits every live row in order");
bool threw = false;
try {
table.Append(Transaction{ "late", "", "", "", Currency::Euro, 0, At(50) });
} catch (const DB::OrderError&) {
threw = true;
}
Check(threw, "out-of-order append throws OrderError");
CheckEqual<std::uint64_t>(table.Size(), 6, "failed append added nothing");
}
{
const DB::Table<Transaction> reopened(path, { .mode = DB::OpenMode::OpenExisting });
CheckEqual<std::uint64_t>(reopened.Size(), 6, "size after reopen");
const std::vector<Transaction> all = reopened.Materialize();
Check(all.size() == 6 && std::equal(sample.begin(), sample.end(), all.begin()), "rows survive reopen");
Check(reopened.Back()[&Transaction::name] == "Emplaced", "Emplaced row survives reopen");
const DB::Inspection info = DB::TableFile::Inspect(path);
CheckEqual<std::uint32_t>(info.header.schemaVersion, 1, "Inspect: schema version");
CheckEqual<std::size_t>(info.schema.columns.size(), 8, "Inspect: column count incl. flags");
CheckEqual<std::uint64_t>(info.commit.rowCount, 6, "Inspect: committed rows");
Check(info.header.schemaFingerprint == DB::Layout<Transaction>::fingerprint, "Inspect: fingerprint matches Layout");
Check(DB::MakeSchemaInfo<Transaction>().Fingerprint(DB::Layout<Transaction>::orderedColumn) == DB::Layout<Transaction>::fingerprint, "runtime and compile-time fingerprints agree");
CheckEqual<std::uint32_t>(DB::PeekVersion(path), 1, "PeekVersion");
}
{
const DB::Table<Transaction> frozen(path, { .readOnly = true });
CheckEqual<std::uint64_t>(frozen.Size(), 6, "read-only open sees rows");
bool threw = false;
try {
DB::Table<Transaction>(path, { .readOnly = true }); // second shared lock must coexist
} catch (const DB::Error&) {
threw = true;
}
Check(!threw, "two read-only opens coexist");
}
std::uint64_t temps = 0;
for (const fs::directory_entry& entry : fs::directory_iterator(dir / "sub")) {
if (entry.path().filename().string().find(".tmp-") != std::string::npos) temps++;
}
CheckEqual<std::uint64_t>(temps, 0, "no temp files left behind");
bool threwExisting = false;
try {
DB::Table<Transaction> missing(dir / "missing.cdb", { .mode = DB::OpenMode::OpenExisting });
} catch (const DB::IOError&) {
threwExisting = true;
}
Check(threwExisting, "OpenExisting on a missing file throws IOError");
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}

111
tests/ShouldVerify/main.cpp Normal file
View file

@ -0,0 +1,111 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Verify() reports a heap handle pointing outside the committed heap and an
// ordered column out of sequence, and is silent on a healthy file; the
// type-erased DynamicRow reads any column by name without the struct.
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 Reading {
std::string sensor;
std::optional<std::string> comment;
double value;
std::int64_t at;
static constexpr std::uint32_t version = 1;
static constexpr auto orderedBy = &Reading::at;
};
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-verify";
fs::remove_all(dir);
const fs::path path = dir / "r.cdb";
std::uint64_t rowsOffset = 0;
{
DB::Table<Reading> table(path);
table.Append({ "thermometer-kitchen", std::nullopt, 21.5, 100 });
table.Append({ "hygrometer", std::string("calibrated last week"), 40.0, 200 });
table.Append({ "barometer-outside-wall", std::nullopt, 1013.25, 300 });
table.Erase(1);
const DB::VerifyResult healthy = table.Verify();
Check(healthy.Ok(), healthy.Ok() ? "healthy table verifies" : std::format("healthy table verifies: {}", healthy.problems[0]));
const DB::DynamicRow dynamic = table.File().Dynamic(2);
const std::optional<std::size_t> sensor = dynamic.Find("sensor");
Check(sensor && dynamic.Text(*sensor) == "barometer-outside-wall", "DynamicRow text by name");
const std::optional<std::size_t> value = dynamic.Find("value");
Check(value && dynamic.Scalar(*value) && std::get<double>(*dynamic.Scalar(*value)) == 1013.25, "DynamicRow double by name");
const std::optional<std::size_t> comment = dynamic.Find("comment");
Check(comment && !dynamic.Present(*comment), "DynamicRow sees a disengaged optional");
Check(table.File().Dynamic(1).Deleted() && table.File().Dynamic(1).Present(*comment) && table.File().Dynamic(1).Text(*comment) == "calibrated last week", "DynamicRow on a tombstoned row with an engaged optional");
const std::string described = dynamic.Describe();
Check(described.find("sensor=\"barometer-outside-wall\"") != std::string::npos && described.find("comment=null") != std::string::npos && described.find("at=300") != std::string::npos, std::format("Describe: {}", described));
Check(!dynamic.Find("nope").has_value(), "unknown column name");
table.Flush();
const DB::Inspection info = DB::TableFile::Inspect(path);
rowsOffset = info.header.extents[0][0].fileOffset;
}
// Damage the heap offset of row 2's long sensor name (heap value).
{
const fs::path damaged = dir / "handle.cdb";
fs::copy_file(path, damaged);
const DB::Inspection info = DB::TableFile::Inspect(damaged);
std::uint16_t sensorOffset = 0;
for (std::size_t i = 0; i < info.schema.columns.size(); i++) {
if (info.schema.Name(i) == "sensor") sensorOffset = info.schema.columns[i].offset;
}
const std::uint64_t bogus = 1 << 30;
Patch(damaged, rowsOffset + 2 * info.header.rowSize + sensorOffset + 8, std::as_bytes(std::span(&bogus, 1)));
const DB::Table<Reading> table(damaged, { .readOnly = true });
const DB::VerifyResult result = table.Verify();
Check(!result.Ok() && result.problems[0].find("row 2") != std::string::npos && result.problems[0].find("sensor") != std::string::npos, result.Ok() ? "damaged handle reported" : std::format("damaged handle reported: {}", result.problems[0]));
bool threw = false;
try {
[[maybe_unused]] const std::string_view text = table[2][&Reading::sensor];
} catch (const DB::CorruptError&) {
threw = true;
}
Check(threw, "reading through the damaged handle throws CorruptError instead of faulting");
Check(table[0][&Reading::sensor] == "thermometer-kitchen", "other rows still read");
}
// Break the order of the ordered column.
{
const fs::path damaged = dir / "order.cdb";
fs::copy_file(path, damaged);
const DB::Inspection info = DB::TableFile::Inspect(damaged);
const std::int64_t early = 50;
Patch(damaged, rowsOffset + 2 * info.header.rowSize + info.schema.columns[info.header.orderedColumn].offset, std::as_bytes(std::span(&early, 1)));
const DB::Table<Reading> table(damaged, { .readOnly = true });
const DB::VerifyResult result = table.Verify();
Check(!result.Ok() && result.problems[0].find("out of order") != std::string::npos, result.Ok() ? "order violation reported" : std::format("order violation reported: {}", result.problems[0]));
}
fs::remove_all(dir);
if (failures == 0) std::println("ok");
return failures == 0 ? 0 : 1;
}