Crafter.DB/AGENTS.consumer.md
Jorijn van der Graaf 38c9076e1d
docs: agent notes for this repository and for its consumers
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>
2026-10-31 20:52:57 +01:00

86 lines
20 KiB
Markdown

## 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 `.commit` to pin), or `LocalProject({ .projectFile = "../Crafter.DB/project.cpp" })` for a sibling checkout, into `cfg.dependencies`. It is a static library whose partitions are nearly all templates: everything a `Table<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 separate `bin/` directory per flag combination); strip your own flags from the `.args` you 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-gnu` and `aarch64-alpine-linux-musl` have been built; the tests run on `*-linux-gnu`). A project that also targets `wasm32-*` or `x86_64-w64-mingw32` adds it to `cfg.dependencies` only when `cfg.target` contains `-linux-`, and keeps the code that uses it behind the same condition. The compiler is crafter-build's clang 23 with `import std;`; the library leans on C++26 features (structured-binding packs, pack indexing, generated `static_assert` messages) that no other toolchain in use here has.
- Nothing is defined for you to `#ifdef` on. Compile-time facts about a table come from `DB::Layout<T>`: `rowSize`, `rowAlign`, `columnCount`, `fingerprint`, and `columns[i].kind`, `.offset` and `.name.View()` (the name is a fixed-capacity text, not a `std::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::duration` and `time_point` (rep plus period, with the clock in the schema), `std::string`, `std::vector<E>` for trivially copyable non-pointer `E`, `std::optional<M>` of a storable `M` (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 to `outer.inner` columns), and anything with a `Crafter::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::Emplace` builds 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 `Transaction` is 88 bytes. A `std::string` or `std::vector` column 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 as `std::uint64_t` or `double` always 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 a `FixedString<N>`.
- Your own types: specialise `Formatter<T>` the way you would `std::formatter`. A fixed-width one declares `using Stored = <storable type>;` with `static Stored Encode(const T&)` and `static T Decode(const Stored&)`; a variable-length one inherits `Formatter<std::string>` (or `Formatter<std::vector<E>>`) and overrides `static Var Encode(const T&, HeapWriter&)` and `static T Materialize(StringRef)`. A row still hands out the raw `StringRef` or `BlobRef` for a variable-length member; `Materialize()` is where your type comes back.
- Once the struct is settled, pin `DB::Layout<T>::rowSize` and `DB::Layout<T>::fingerprint` with a `static_assert` in a test: a failing assert is the compile-time form of the `SchemaError` your users would get at open, and tells you a `version` bump 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 `mmap` each: 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. A `Table` is move-only: hold it in a member or a `static`, 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 writable `Table` on the file, in another process or in this one, throws `LockedError`, 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: `cp` and 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 last `Flush`.
- **Durability is at `Flush()`, `Close()` and destruction, not at `Append`.** Under `Durability::OnFlush` (the default) an appended row is visible to this process at once and reaches the disk at the next `Flush`; a crash loses what was not flushed and never a committed row. An `Update` or `Set` that writes heap bytes (a string longer than 12 bytes, a blob) flushes on its own, an `fdatasync` each, and so commits every row appended before it. `Durability::EveryAppend` flushes after every write. The destructor flushes but cannot report; call `Close()` where a failed final flush must throw. After `fork()` the child may read the inherited table; every write and every explicit `Flush()` in it throws `Error`, while `Close()` 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 while `Compact`, `Clear` or a migration is writing (stale ones go on the next writable open), and `<file>.v<old>.bak` after 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; `.cdb` is the convention.
- **Address space.** Each open table reserves `reserveBytes` twice (rows and heap), 64 GiB of virtual space by default, none of it committed. Under a `ulimit -v`, or with hundreds of tables open, lower `reserveBytes` to a few times the table's size; a reservation that fills during an append throws `IOError` naming 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 `orderedBy` table a write whose key would sort before the previous row (or, for `Update` and `Set`, after the next) throws `OrderError` before anything is written; equal keys are allowed; an unsorted batch is refused whole. Rows must arrive in key order: sort before `Append`, or leave `orderedBy` out and sort when reading.
- `Update`, `Set` and `Erase` leave heap garbage (the old string bytes); `Stats().heapGarbage` says how much, and only `Compact()` reclaims it. `Compact` and `Clear` invalidate every `RowId`, `Row` and `StringRef` taken before them; `Update` and `Set` invalidate the views of that row; `Append` and `Erase` invalidate nothing.
- `Update` and `Set` never 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 (`IOError` saying to `Compact()`), and the disk (`fallocate` fails with `IOError` before any page is touched, never a `SIGBUS`).
### 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 with `std::views`; `table.Slots()` is random access over every slot with `row.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 chainable `Query`: `Materialize()`, `Count()`, `First()` (an `std::optional<Row<T>>`), `Any()`, `Where()`, `Select<&T::m>()`. `pred` is a lambda over `Row<T>` or a column expression built from tags, `inline constexpr DB::Column<&T::iban> iban{};` and then `iban == x && amount > 100'00`, with `== != < <= > >= && || !`. Operands are copied into the expression, so a temporary `std::string` is fine.
- **Encode the key once.** In a lambda, compare against a `const DB::Key key(text);` built outside the loop rather than a `std::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: no `std::function`, no per-row call. Measured on 100 000 rows (Xeon w7-3465X, warm cache): `iban == x` at 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`, `LowerBound` and `UpperBound` are binary searches and compile only for the member `orderedBy` names (a `static_assert` says so for any other). `table.Range<…>(a, b).Where(iban == x)` filters inside the slice, and `table.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 until `Compact()`. `std::ranges::lower_bound(table.Slots(), key, {}, [](DB::Row<T> r) { return r[&T::m]; })` is the escape hatch for any other column.
- A `Query` over a plain range (`Rows`, `Range`, `From` and the like) can be iterated as a `const` object; one built with `Where` cannot (`std::ranges::filter_view`), so hold it in a non-const local. `Select()` on a `Query` is rvalue-only: call it on the temporary, or `std::move` the query.
- Threads: writes come from one thread at a time. While it runs `Append`, `Emplace` or `Erase`, other threads may read (a scan that started before an `Erase` may still yield the row); `Update`, `Set`, `Compact` and `Clear` need exclusive access from you. Views stay valid across `Append` and `Erase`.
### 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 point `using 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 needs `static T Migrate(Previous&&)`; without it the build fails naming the member. Chains are walked (`V1 → V2 → V3`) and the versions along `Previous` must 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 new `orderedBy` are 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 opened `readOnly` or with `migrate = false` is a `MigrationError`. `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 (an `Inspection`) without the struct and without the lock; and `DB::DynamicRow` reads 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` `IOError`s 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_all` first and last. crafter-build runs tests in parallel, so each test gets a directory of its own.
- Destroy or `Close()` a writable `Table` before opening the same file again, and a read-only one before opening it writable: the lock is held until then, and the conflicting open throws `LockedError` in 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.cpp` in the Crafter.DB repository has the helper.
- End a test that exercised writes with `table.Verify().Ok()`, and pin `DB::Layout<T>::rowSize` and `fingerprint` with `static_assert`s so an accidental schema change fails the build rather than a user's open.
- `std::format("{}", row)` and `DB::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.