AGENTS.template.md is what crafter-build writes AGENTS.md from on every build and test run: the partition map, the toolchain facts the code leans on, build and test commands, test conventions, code style, the design rules with their reasons, commit guidelines and the gotchas found in the storage and file layers. AGENTS.consumer.md is the section crafter-build copies into the AGENTS.md of every project that depends on this library: declaring the dependency, the table-struct rules, locking and durability, writing and querying, migrations, an errors table and how to test a consumer. Both were fact-checked claim by claim against the interfaces, the implementation units and the tests. AGENTS.md is build output and is now gitignored, as in the sibling libraries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 KiB
Crafter.DB
The Catcrafts embedded table store: a C++26 module, import Crafter.DB;, in which a table is a plain struct with a version, a file is a memory-mapped array of fixed-width rows with an out-of-line heap for strings and blobs, reads are zero-copy views, queries are lambdas or column expressions that compose with std::ranges, and schema changes are C++ migrations run on open. Everything is in namespace Crafter::DB (the examples below assume using namespace Crafter;). Linux only: the engine uses flock, fallocate and fixed-address mmap. No dependencies, no link flags. Source and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.DB (LGPL-3.0-only). The README there is the manual, with the whole API and the file format; these notes are its AGENTS.consumer.md: a fix to them goes there, not into the copy crafter-build writes.
Building against it
GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.DB.git" } })(add.committo pin), orLocalProject({ .projectFile = "../Crafter.DB/project.cpp" })for a sibling checkout, intocfg.dependencies. It is a static library whose partitions are nearly all templates: everything aTable<T>does is instantiated in your translation units with your target and--march, and the only out-of-line code is the OS layer and the type-erased engine. It reads no project args and has no dependencies of its own, so a flag of yours that reaches it only changes its variant hash (a separatebin/directory per flag combination); strip your own flags from the.argsyou pass down if you would rather not, and pass the same list on every path that reaches it, or the build stops with two loads that disagree.- Only Linux targets build it (
x86_64-unknown-linux-gnuandaarch64-alpine-linux-muslhave been built; the tests run on*-linux-gnu). A project that also targetswasm32-*orx86_64-w64-mingw32adds it tocfg.dependenciesonly whencfg.targetcontains-linux-, and keeps the code that uses it behind the same condition. The compiler is crafter-build's clang 23 withimport std;; the library leans on C++26 features (structured-binding packs, pack indexing, generatedstatic_assertmessages) that no other toolchain in use here has. - Nothing is defined for you to
#ifdefon. Compile-time facts about a table come fromDB::Layout<T>:rowSize,rowAlign,columnCount,fingerprint, andcolumns[i].kind,.offsetand.name.View()(the name is a fixed-capacity text, not astd::string_view).
Defining a table
A table struct is an aggregate (public members, no base classes with members, no user-declared constructors), default constructible, with static constexpr std::uint32_t version = 1;. Optional statics: static constexpr auto orderedBy = &T::member; (an integer, enum, or integral-rep duration or time_point member; enforced on every write, enables binary search), using Previous = OlderT; and static T Migrate(OlderT&&) (see "Schema changes and migrations"). Every rule about the struct and its members is a static_assert whose message starts with Crafter.DB: and names them, so read the first error, not the instantiation stack.
- Storable members: arithmetic types except
long double,bool,std::byte, enums (stored as their underlying integer),std::chrono::durationandtime_point(rep plus period, with the clock in the schema),std::string,std::vector<E>for trivially copyable non-pointerE,std::optional<M>of a storableM(not of another optional),std::array<E, N>of scalars (one column per element) or of bytes (one fixed column),DB::FixedString<N>(N bytes inline, NUL padded), nested aggregates of storable members (flattened toouter.innercolumns), and anything with aCrafter::DB::Formatter<T>specialisation. Pointers, references and member pointers are rejected. - A struct with constructors is not an aggregate and is refused with a message that says to replace them with designated initialisers;
Table::Emplacebuilds rows from fields, so the constructors are not missed. - Columns are not laid out in declaration order: the flags byte sits at offset 0 and the leaves follow in ascending alignment, 2-byte columns before 8-byte ones. The README's seven-member
Transactionis 88 bytes. Astd::stringorstd::vectorcolumn is a 16-byte handle holding values of up to 12 bytes inline, and the first 4 bytes of longer ones (a vector of 8-byte-aligned elements such asstd::uint64_tordoublealways goes to the heap), so equality against such a column is one 8-byte compare per row unless the lengths and prefixes match. Keys you filter on often are best kept at 12 bytes or under where the data allows (a BIC is, an IBAN is not); a code with a fixed maximum length is aFixedString<N>. - Your own types: specialise
Formatter<T>the way you wouldstd::formatter. A fixed-width one declaresusing Stored = <storable type>;withstatic Stored Encode(const T&)andstatic T Decode(const Stored&); a variable-length one inheritsFormatter<std::string>(orFormatter<std::vector<E>>) and overridesstatic Var Encode(const T&, HeapWriter&)andstatic T Materialize(StringRef). A row still hands out the rawStringReforBlobReffor a variable-length member;Materialize()is where your type comes back. - Once the struct is settled, pin
DB::Layout<T>::rowSizeandDB::Layout<T>::fingerprintwith astatic_assertin a test: a failing assert is the compile-time form of theSchemaErroryour users would get at open, and tells you aversionbump is due.
Opening and lifetime
DB::Table<T> table(path); creates the file when missing (directories 0700, file 0600), migrates it when older, and throws when it cannot. DB::Table<T>(path, { .mode = DB::OpenMode::OpenExisting, .readOnly = true }) opens a frozen reader. OpenOptions takes designated initialisers in this order: mode (OpenOrCreate, OpenExisting, CreateNew), readOnly, createDirectories, filePermissions, directoryPermissions, durability (OnFlush, EveryAppend), migrate, backupBeforeMigrate, reserveBytes (32 GiB), excludeFromCoreDumps, prefetch.
- Open once per process and keep the table open. An open maps every extent of the file (one
mmapeach: 11 for the Benchmark's 100 000 rows appended in one batch, about 20 for the same rows appended one at a time) and a read-only open costs about 30 µs, which dominates any code that opens a table per query. ATableis move-only: hold it in a member or astatic, not in each function that reads. - One writer per file at a time, across processes and within one. A writer holds an exclusive
flock, a read-only table a shared one: a second writableTableon the file, in another process or in this one, throwsLockedError, a read-only table anywhere makes a writer's open fail, and a writer makes every reader's. Read-only tables coexist. Two processes that create the same table at once end up sharing one file. The lock is advisory:cpand other tools ignore it, and a copy taken while a writer is open reads back as of the writer's last commit record, which is its lastFlush. - Durability is at
Flush(),Close()and destruction, not atAppend. UnderDurability::OnFlush(the default) an appended row is visible to this process at once and reaches the disk at the nextFlush; a crash loses what was not flushed and never a committed row. AnUpdateorSetthat writes heap bytes (a string longer than 12 bytes, a blob) flushes on its own, anfdatasynceach, and so commits every row appended before it.Durability::EveryAppendflushes after every write. The destructor flushes but cannot report; callClose()where a failed final flush must throw. Afterfork()the child may read the inherited table; every write and every explicitFlush()in it throwsError, whileClose()and the destructor skip the flush silently, so the child's stale counters never reach the file. - Files beside the table:
<file>.tmp-<12 hex>while the table is being created and whileCompact,Clearor a migration is writing (stale ones go on the next writable open), and<file>.v<old>.bakafter a migration (a hard link of the pre-migration file; delete it when it has served). The directory must stay writable for those three operations. Use a directory per table or distinct basenames;.cdbis the convention. - Address space. Each open table reserves
reserveBytestwice (rows and heap), 64 GiB of virtual space by default, none of it committed. Under aulimit -v, or with hundreds of tables open, lowerreserveBytesto a few times the table's size; a reservation that fills during an append throwsIOErrornaming the option. - A table created on a 4 KiB-page kernel cannot be opened on a 16 KiB-page one (
IOError,not_supported); the extent granule is recorded in the header.
Writing
Append(row) and Append(range) return the RowId of the first new row (ids are slot indices from 0, stable until Compact or Clear); a range is one reservation and one publish, all or nothing. Emplace(args...) takes exactly one argument per member in declaration order, converted implicitly (a std::string_view is enough for a string member; an int for an enum member does not compile). Update(id, row) replaces a live row; Set<&T::member>(id, value) one member; Erase(id) tombstones (false when already erased); EraseIf(pred) returns the count; Flush(); Compact() rewrites without tombstones and heap garbage; Clear() empties.
- On an
orderedBytable a write whose key would sort before the previous row (or, forUpdateandSet, after the next) throwsOrderErrorbefore anything is written; equal keys are allowed; an unsorted batch is refused whole. Rows must arrive in key order: sort beforeAppend, or leaveorderedByout and sort when reading. Update,SetandEraseleave heap garbage (the old string bytes);Stats().heapGarbagesays how much, and onlyCompact()reclaims it.CompactandClearinvalidate everyRowId,RowandStringReftaken before them;UpdateandSetinvalidate the views of that row;AppendandEraseinvalidate nothing.UpdateandSetnever leave a row pointing at heap bytes that are not on disk (those are committed first), but the row's fixed-width columns are not written atomically: a crash or power loss mid-row leaves the old row, the new one, or a mix of the two. A log of appends is the shape the library is built for.- Limits: a value below 4 GiB (
Error), a row of at most 65535 bytes and 65535 columns (compile time), a region of at most 32 extents, roughly 17 GiB (IOErrorsaying toCompact()), and the disk (fallocatefails withIOErrorbefore any page is touched, never aSIGBUS).
Reading and querying
table.Size() is the live rows, SlotCount() the id space including tombstones, table[id] an unchecked Row<T> view (16 bytes, no allocation), At(id) a checked one, Contains(id), Front(), Back() (skips trailing tombstones). row[&T::member] and row.Get<&T::member>() read one column from the mapping: the value for scalars, chrono and FixedString; a StringRef for a string (converts to std::string_view, compares with == to literals, std::string_view, std::string, Key and other refs without building a view, orders with <=>); a BlobRef<E> for a vector (converts to std::span<const E>); an std::optional of the view for an optional. row.Materialize() (also explicit operator T) copies the row out; table.Materialize() every live row. std::format("{}", row) prints Type{member=value, …}.
for (DB::Row<T> row : table)is the live rows in id order and composes withstd::views;table.Slots()is random access over every slot withrow.Erased();table.Rows(first, last)a slot range;table.Select<&T::member>()a view of one column.table.Where(pred)is lazy and returns a chainableQuery:Materialize(),Count(),First()(anstd::optional<Row<T>>),Any(),Where(),Select<&T::m>().predis a lambda overRow<T>or a column expression built from tags,inline constexpr DB::Column<&T::iban> iban{};and theniban == x && amount > 100'00, with== != < <= > >= && || !. Operands are copied into the expression, so a temporarystd::stringis fine.- Encode the key once. In a lambda, compare against a
const DB::Key key(text);built outside the loop rather than astd::string_view(correct, but the encoding is redone per row); a column expression does it for you. The predicate is a template parameter and inlines into the scan: nostd::function, no per-row call. Measured on 100 000 rows (Xeon w7-3465X, warm cache):iban == xat 2 to 5 ns per row, a time range covering 10 % of the rows about 1 ms materialised, every row materialised 10 to 13 ms. - The ordered column.
Range<&T::key>(from, to)(inclusive),From,After,Until,Before,LowerBoundandUpperBoundare binary searches and compile only for the memberorderedBynames (astatic_assertsays so for any other).table.Range<…>(a, b).Where(iban == x)filters inside the slice, andtable.Where(timePoint >= a && timePoint <= b && iban == x)does the same: bounds on the ordered column in a top-level&&chain become the search. Under||or!nothing is extracted and the expression runs as a full scan, still correct. Tombstoned rows keep their key, so searches stay valid untilCompact().std::ranges::lower_bound(table.Slots(), key, {}, [](DB::Row<T> r) { return r[&T::m]; })is the escape hatch for any other column. - A
Queryover a plain range (Rows,Range,Fromand the like) can be iterated as aconstobject; one built withWherecannot (std::ranges::filter_view), so hold it in a non-const local.Select()on aQueryis rvalue-only: call it on the temporary, orstd::movethe query. - Threads: writes come from one thread at a time. While it runs
Append,EmplaceorErase, other threads may read (a scan that started before anErasemay still yield the row);Update,Set,CompactandClearneed exclusive access from you. Views stay valid acrossAppendandErase.
Schema changes and migrations
The file carries the struct's version and a fingerprint of the layout: column names, kinds, offsets, sizes, flags, chrono period and clock, blob element type and the ordered column. Any change to those without a version bump is refused at open with a SchemaError listing both column sets. That includes renaming a member, changing a type or an enum's underlying type, reordering members of the same alignment class, adding or changing orderedBy, and changing a Formatter's Stored.
- Bump
version, keep the old struct under another name (TransactionV1), and pointusing Previous = TransactionV1;at it. Added members need nothing more: old rows are copied by member name and new members value-initialised. A removed, renamed or retyped member needsstatic T Migrate(Previous&&); without it the build fails naming the member. Chains are walked (V1 → V2 → V3) and the versions alongPreviousmust strictly decrease. Keep every old struct for as long as files of that version may exist. - The migration runs in the constructor on the first open with the newer code: every live row is streamed through the chain into a new file that replaces the old one atomically, and the old one is kept as
<file>.v<old>.bak(backupBeforeMigrate). Rows that do not satisfy a neworderedByare stable-sorted by it. A large table makes that first open take a while, once. - Refusals: a file newer than the code, or one the chain does not reach, is a
SchemaError; an older file openedreadOnlyor withmigrate = falseis aMigrationError.DB::PeekVersion(path)reads the version from the header alone, without the lock;DB::Migrate<T>(path)migrates without keeping the table open;DB::TableFile::Inspect(path)returns header, commit and schema (anInspection) without the struct and without the lock; andDB::DynamicRowreads columns by name from any table, for dumps and tools. - The file format itself has a
formatVersion(1) that must match exactly; a bump there is a library release note, not something a consumer handles.
Errors
Everything the library throws derives from Crafter::DB::Error, a std::runtime_error, except where noted. Most messages start with the file path; the 4 GiB Error, the heap-handle CorruptError and the address-space and msync IOErrors carry none.
| Call | On failure |
|---|---|
Table constructor, Migrate<T> |
IOError (with code and path: a missing file under OpenExisting, an existing one under CreateNew, permissions, page-size mismatch, address space), CorruptError (not a table, unsupported format, both commit records damaged, extents or schema out of bounds), SchemaError, MigrationError, LockedError |
PeekVersion, TableFile::Inspect |
IOError (missing file, permissions), CorruptError; neither takes the lock |
Append, Emplace, Update, Set, Erase, EraseIf, Compact, Clear |
ReadOnlyError; Error on a closed table or one inherited through fork; OrderError before anything is written; IOError when the disk, the extent table or the reservation is full; Error for a value of 4 GiB or more; std::length_error for a FixedString that does not fit; CorruptError from Compact on a damaged heap handle |
Flush, Close |
IOError from fdatasync or msync; Flush throws Error in a forked child, where Close skips the flush; Flush is a no-op on a read-only table, Close still closes it and releases the lock |
Update, Set, Erase, At with an id outside the table, or (Update, Set, At) an erased one; Front, Back on an empty table |
std::out_of_range (operator[] does not check) |
StringRef::View, BlobRef::View, Materialize, any read of a long value |
CorruptError when the handle points outside the committed heap, which is a damaged file |
Verify() |
never throws for content; returns a VerifyResult listing the problems, at most a thousand |
API misuse: a non-aggregate or version-less struct, an unstorable member, orderedBy on a float or string, Range on another member, a wrong Emplace argument, a Previous member with no counterpart |
a compile error whose static_assert message starts with Crafter.DB: |
Testing a consumer
- Work under
fs::temp_directory_path() / "<project>-<test>",remove_allfirst and last. crafter-build runs tests in parallel, so each test gets a directory of its own. - Destroy or
Close()a writableTablebefore opening the same file again, and a read-only one before opening it writable: the lock is held until then, and the conflicting open throwsLockedErrorin the same process. - A crash test is a
fork()ed child that writes and ends with_exit(0)while its table is still in scope, so no destructor flushes; the parent then opens the file and sees exactly the flushed rows.tests/ShouldRecoverCommits/main.cppin the Crafter.DB repository has the helper. - End a test that exercised writes with
table.Verify().Ok(), and pinDB::Layout<T>::rowSizeandfingerprintwithstatic_asserts so an accidental schema change fails the build rather than a user's open. std::format("{}", row)andDB::TableFile::Inspect(path)are the debugging tools;table.File().Dynamic(id).Describe()prints any row of any table by column name.
Names to get right
Materialize (with a z), Emplace, Erase and EraseIf (not Delete or Remove), Compact, Verify, Stats, Flush, Close; Formatter is the customisation point even though it encodes bytes, not text; the statics are version and orderedBy (camelCase) and Previous and Migrate (PascalCase); RowId is a std::uint64_t; Key is the pre-encoded search key, StringRef and BlobRef the column views, Column<&T::m> the tag type.