C++ library for local database with queries written in C++ and no SQL strings.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jorijn van der Graaf bfd0cc261b
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>
2026-10-17 04:16:51 +02:00
implementations Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
interfaces Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
tests Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
.gitignore Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
LICENSE Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
LICENSE.GPL Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
project.cpp Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00
README.md Initial commit: Crafter.DB 2026-10-17 04:16:51 +02:00

Crafter.DB

An embedded, typed table store for C++26: a table is a plain struct, a file is a memory-mapped array of fixed-width rows, queries are C++. Built for the Catcrafts projects (it replaces Nostro's hand-rolled transaction log) and distributed as one C++ module, Crafter.DB.

  • Structs are the schema. Any aggregate with a version is a table. No macros, no registration, no code generation: member names, types and offsets are derived at compile time.
  • Fixed-width rows, out-of-line strings. Every row is rowSize bytes. Strings and blobs live in a heap region; the row holds a 16-byte handle that keeps values of up to 12 bytes, and the first 4 bytes of longer ones, inline, so iban == x is one 8-byte compare per row.
  • Zero-copy reads. Row<T> is a 16-byte view; row[&T::iban] reads one column straight from the mapping. Materialize() copies only when you ask.
  • Queries are C++. Lambdas over rows, or column expressions (iban == x && amount > 100), composed with std::ranges. A declared orderedBy column gives binary search (Range, From, After), also from a combined expression.
  • Versioned, with C++ migrations. The file carries the struct's version and a layout fingerprint. A struct that changed without a version bump is refused; an older file is migrated through the Previous chain on open.
  • Crash safe appends. Two CRC-protected commit records; a crash loses at most what was not yet flushed, never committed rows.
  • Private by default. Files are created 0600 in 0700 directories and excluded from core dumps.

Modules

import Crafter.DB; re-exports these partitions, all in namespace Crafter::DB:

Partition Contents
:Table Table<T>: open/create/migrate, Append, Emplace, Update, Set, Erase, EraseIf, Compact, Clear, Flush, iteration, Where, Select, Range/From/After/Until/Before, LowerBound/UpperBound, Verify.
:Row Row<T> (column views, Materialize), RowId, Column<&T::m> tags, LiveRange, SlotView, std::formatter<Row<T>>.
:Query Column expressions (==, !=, <, <=, >, >=, &&, ||, !), bound extraction, the chainable Query result (Materialize, Count, First, Any, Where, Select).
:Formatter Formatter<T>, the customisation point for member types; StringRef, BlobRef<E>, Key, FixedString<N>; built-in formatters for std::string, std::vector<Trivial>, std::chrono.
:Schema Layout<T>: the compile-time row layout, fingerprint and diagnostics; TableType, HasOrderedBy, HasPrevious, HasMigrate.
:Migration Migrate<T>(path), PeekVersion(path), the chain walk and the by-name copy.
:Storage The type-erased engine: TableFile, Inspect(path), DynamicRow, VerifyResult, TableStats.
:File OpenOptions, OpenMode, Durability; file descriptors, locks, reserved mappings.
:Format The on-disk structures (FileHeader, CommitRecord, ColumnDescriptor, Var), Kind, FNV-1a, CRC-32C, SchemaInfo.
:Codec Typed encode/decode over a Layout<T>.
:Reflect Compile-time member count, names, types and pointer-to-member lookup for aggregates.
:Error Error and its subclasses.

Defining a table

import Crafter.DB;
using namespace Crafter;

enum class Currency : std::uint16_t { Euro };

struct Transaction {
    std::string name;
    std::string bic;
    std::string iban;
    std::string description;
    Currency currency;                                   // enums, integers, floats: stored as is
    std::int64_t amount;
    std::chrono::system_clock::time_point timePoint;     // built-in formatter: rep + period

    static constexpr std::uint32_t version = 1;                  // required
    static constexpr auto orderedBy = &Transaction::timePoint;   // optional: enforced on writes, enables Range()
};

A table struct is an aggregate (public members, no base classes with members, no user-declared constructors) that is default constructible and declares version. Every member must be storable: arithmetic types, bool, std::byte, enums, std::chrono::duration and time_point, std::string, std::vector<E> for trivially copyable E, std::optional<M>, std::array<E, N>, FixedString<N>, nested aggregates of storable members (flattened into outer.inner columns), and anything with a Formatter. Pointers and references are rejected at compile time; each static_assert names the table and the member.

The row above is 88 bytes: a flags byte, currency, four 16-byte string handles, amount, timePoint.

Storing your own types

Specialise Crafter::DB::Formatter<T> the way you would std::formatter. A fixed-width formatter maps to a storable Stored type:

template<>
struct Crafter::DB::Formatter<Percent> {
    using Stored = std::int32_t;                                   // basis points
    static Stored Encode(const Percent& value) noexcept { return value.BasisPoints(); }
    static Percent Decode(const Stored& stored) noexcept { return Percent(stored / 100.0); }
};

A variable-length formatter writes into the heap. Inheriting the std::string one and overriding the ends is the common shape:

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()); }   // row[&T::tags] still hands out the raw StringRef
};

Opening a table

DB::Table<Transaction> table(cachePath / "transactions" / "bunq" / "main.cdb");          // created if missing, migrated if older
DB::Table<Transaction> frozen(path, { .mode = DB::OpenMode::OpenExisting, .readOnly = true });

OpenOptions (designated initialisers): mode (OpenOrCreate, OpenExisting, CreateNew), readOnly, createDirectories, filePermissions (0600), directoryPermissions (0700), durability (OnFlush: durable after Flush() and on destruction; EveryAppend), migrate, backupBeforeMigrate (keeps <file>.v<old>.bak), reserveBytes (virtual address space per region, 32 GiB), excludeFromCoreDumps, prefetch.

One Table per file at a time: a writer takes an exclusive flock, read-only tables take shared locks. A conflicting open throws LockedError, in the same process as well as from another. Two processes creating the same table at once end up sharing one file (the create never replaces an existing file).

The destructor flushes but cannot report a failure; call Close() when a failed final flush must surface as an exception. After fork(), the child may read an inherited table (the mapping is shared) but every write, Flush and the destructor's flush are refused in the child, so its stale counters never reach the file.

Writing

DB::RowId id = table.Append(tr);                      // ids are slot indices
table.Append(std::span<const Transaction>(rows));     // one reservation, one publish
table.Emplace("Albert Heijn", "ABNANL2A", iban, "groceries", Currency::Euro, -2345, now);   // one argument per member, strings need no std::string
table.Update(id, tr);
table.Set<&Transaction::description>(id, "corrected");
table.Erase(id);                                       // tombstone; ids stay valid
table.EraseIf([](DB::Row<Transaction> r) { return r[&Transaction::amount] == 0; });
table.Flush();                                         // fdatasync + commit record
table.Compact();                                       // rewrite without tombstones and heap garbage; invalidates ids and views

On an orderedBy table, a write whose key would break the order throws OrderError before anything is written; a batch is all-or-nothing. Update and Set commit any new heap bytes to disk before the row starts pointing at them, so a crash leaves the old row or the new one, never a dangling reference; the row bytes themselves are not atomic against a power loss mid-write.

Reading and querying

table.Size();                                     // live rows
DB::Row<Transaction> last = table.Back();         // view, no allocation
auto when = last[&Transaction::timePoint];        // value
DB::StringRef iban = last[&Transaction::iban];    // view into the file; converts to std::string_view
Transaction copy = last.Materialize();

for (DB::Row<Transaction> row : table) ...        // live rows in id order
std::vector<Transaction> all = table.Materialize();

// predicates: a lambda, or column tags
const DB::Key key(ibanText);                      // encode the search key once, compare with one 8-byte load per row
auto rows = table.Where([&](DB::Row<Transaction> r) { return r[&Transaction::iban] == key; }).Materialize();

inline constexpr DB::Column<&Transaction::iban> iban{};
inline constexpr DB::Column<&Transaction::amount> amount{};
inline constexpr DB::Column<&Transaction::timePoint> timePoint{};
auto big = table.Where(iban == ibanText && amount > 100'00).Count();

// composes with std::ranges
for (auto row : table | std::views::filter(...) | std::views::take(10)) ...
std::int64_t total = std::ranges::fold_left(table.Select<&Transaction::amount>(), 0LL, std::plus{});

StringRef compares against std::string_view, std::string, literals, Key and other refs without building a std::string_view first: the handle's length-and-prefix word rejects almost every non-match, the heap is read only for prefix-equal values longer than 12 bytes.

Predicates are template parameters: the lambda body inlines into the scan loop. There is no std::function and no per-row call.

Ordered columns

auto inRange = table.Range<&Transaction::timePoint>(from, to);     // from <= key <= to, binary search
auto since   = table.From<&Transaction::timePoint>(from);          // key >= from
auto newer   = table.After<&Transaction::timePoint>(last);         // key >  last
DB::RowId lb = table.LowerBound<&Transaction::timePoint>(from);

// range + property: Range narrows by binary search, Where filters inside it
auto rent = table.Range<&Transaction::timePoint>(from, to).Where(iban == x).Materialize();
// the same from one expression: bounds on the ordered column in a top-level && become the binary search
auto same = table.Where(timePoint >= from && timePoint <= to && iban == x).Materialize();

Keys may repeat; Range is inclusive. orderedBy must be an integer, enum or integral chrono member: floats have NaN, and strings have no binary search here. Tombstoned slots keep their key, so the search stays valid until Compact(). std::ranges::lower_bound(table.Slots(), …) is the escape hatch for anything else.

Migrations

struct TransactionV1 { /* as above */ static constexpr std::uint32_t version = 1; };

struct TransactionV2 {              // added `reference`: additive, no Migrate needed
    /* V1 members + */ std::string reference;
    static constexpr std::uint32_t version = 2;
    using Previous = TransactionV1;
};

struct Transaction {                // renamed description -> memo, merged amount + currency: needs Migrate
    std::string name, bic, iban, memo, reference;
    Money money;
    std::chrono::system_clock::time_point timePoint;

    static constexpr std::uint32_t version = 3;
    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 };
    }
};

Opening a version-1 file with Table<Transaction> streams every live row through V1 → V2 (copied by member name, new members value-initialised) → Migrate → V3 into a new file that replaces the old one atomically; the original stays as main.cdb.v1.bak. When the new struct declares an orderedBy the old rows do not satisfy, the migration sorts them (stably) by the new key. The fingerprint covers column names, kinds, offsets, chrono periods and clocks, blob element types and the ordered column, so any of those changing without a version bump is refused. Without Migrate, every member of Previous must exist in the new struct with the same name and type (a removal or rename fails to compile with a message saying so). A file whose version equals the struct's but whose fingerprint differs throws SchemaError listing both column sets. migrate = false or readOnly on an older file throws MigrationError. DB::Migrate<T>(path) runs a migration without keeping the table open; DB::PeekVersion(path) reads the version alone.

Thread safety and lifetimes

Like a standard container: any number of concurrent readers, writes need exclusive access from the caller. Exception: Append/Emplace/Erase are safe while other threads read (rows are published with a release store, tombstones with an atomic byte and a monotonic flag that switches scans to the checking path). A scan started before an Erase may still yield that row. Row views and StringRefs stay valid across Append and Erase and are invalidated by Update/Set of their row, Compact, Clear, migration and destruction. Extents are mapped into a reserved virtual range, so the mapping never moves while the table is open. A Query over a plain range (Rows, Range, From, …) can be iterated as a const object; one built with Where cannot, because std::ranges::filter_view has no const iteration.

Errors

All exceptions derive from Crafter::DB::Error (a std::runtime_error): IOError (with code and path), CorruptError, SchemaError, MigrationError, LockedError, OrderError, ReadOnlyError. Out-of-range or erased ids throw std::out_of_range from At(); operator[] does not check.

File format

One file per table: a 4 KiB header (magic, format and schema version, fingerprint, extent table, two CRC-32C commit records), schema pages (column descriptors and names, so Inspect() and DynamicRow can read any table without its struct), then extents. Rows and heap are two regions, each a chain of doubling extents appended at the end of the file and mapped back to back. Extents are aligned to the page size of the system that created the file (4 KiB on x86-64); a file created with smaller pages than the running kernel's cannot be mapped and is refused with a clear error. Filesystems without fallocate fall back to posix_fallocate. Appends publish rows to in-process readers immediately and become durable at Flush(): fdatasync, then the inactive commit record, then msync. On open the newest valid commit wins; rows and heap bytes beyond it, and uncommitted extents, are discarded. Deletes set a flag byte; Compact() rewrites. Update is safe against a process crash but not atomic against a power loss mid-row (documented limitation). Little-endian only. Verify() checks every handle, the ordered column and the live count.

Performance

tests/Benchmark compares Crafter.DB with a cleaned-up copy of Nostro's format (iostream, 2-byte length-prefixed strings) on 100 000 transactions, warm cache, one run on a Xeon w7-3465X:

Operation Nostro Crafter.DB Note
append 100 000 rows 16.5 ms 11.1 ms Crafter.DB includes fdatasync; Nostro syncs nothing
last timestamp, 1000 × open + read 1.9 ms 30.0 ms opening a mapped table costs one mmap per extent (about 20 for this size); open once per process
load all, materialised 36.0 ms 10.4 ms zero-copy scan of two columns: 0.8 ms
time range, 10 % of rows 10.0 ms 0.9 ms binary search + contiguous slice
iban == x 16.2 ms 0.23–0.30 ms 2–3 ns per row
time range + iban == x 11.2 ms 0.03 ms

File size for that table: 17.2 MB against Nostro's 9.3 MB (fixed-width handles plus the slack of the last extents; Compact() right-sizes).

Building

This library uses Crafter.Build, which ships clang 23 and libc++ 23:

crafter-build            # static library
crafter-build test       # build and run the tests

Linux only: the engine uses flock, fallocate and fixed-address mmap.

Testing

Each test is tests/<Name>/main.cpp and is declared in project.cpp:

  • ShouldRoundTrip, ShouldKeepLayoutGolden, ShouldKeepViewsStableAcrossGrowth, ShouldCompareStringRefs
  • ShouldQuery, ShouldEraseAndCompact, ShouldFormatCustomTypes
  • ShouldMigrate, ShouldRejectSchemaChange, ShouldRecoverCommits, ShouldLockAcrossProcesses, ShouldVerify, ShouldReadWhileAppending
  • Benchmark (always passes, prints the table above)

License

This library is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See LICENSE for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as LICENSE.GPL.

Copyright (C) 2026 Catcrafts®

catcrafts.net