// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // The on-disk format: byte-exact header, commit record, extent table, schema // blob and the 16-byte variable-length handle. Every struct here is a POD // that is read and written with memcpy; the static_asserts pin each offset so // a reordered member cannot silently change the file format. Little-endian // only (asserted), which is every target Crafter builds for. export module Crafter.DB:Format; import std; export namespace Crafter::DB { inline constexpr std::uint32_t pageSize = 4096; inline constexpr std::uint16_t formatVersion = 1; inline constexpr std::size_t maxExtents = 32; inline constexpr std::size_t regionSlots = 4; inline constexpr std::uint16_t regionCount = 2; // 0 rows, 1 heap; 2-3 reserved inline constexpr std::uint32_t minExtentBytes = 16384; inline constexpr std::uint16_t noColumn = 0xFFFF; inline constexpr std::uint32_t inlineVarBytes = 12; // strings up to this length live in the handle inline constexpr std::array fileMagic = { 'C', 'R', 'A', 'F', 'T', 'R', 'D', 'B' }; inline constexpr std::array schemaMagic = { 'S', 'C', 'H', 'M' }; inline constexpr std::uint32_t formatFlagLittleEndian = 1; inline constexpr std::uint64_t commitFlagCleanClose = 1; inline constexpr std::uint8_t rowFlagDeleted = 1; static_assert(std::endian::native == std::endian::little, "Crafter.DB stores little-endian and does not byte-swap"); // Column kinds as stored in the descriptor table. Append only: values are // part of the file format. enum class Kind : std::uint8_t { Flags = 0, Bool = 1, Int8 = 2, Int16 = 3, Int32 = 4, Int64 = 5, UInt8 = 6, UInt16 = 7, UInt32 = 8, UInt64 = 9, Float32 = 10, Float64 = 11, TimePoint = 12, Duration = 13, String = 14, Blob = 15, FixedBytes = 16, }; inline constexpr std::uint8_t columnFlagOptional = 1; // payload of a std::optional; presenceOffset is valid inline constexpr std::uint8_t columnFlagEnum = 2; // integer column backing an enum inline constexpr std::uint8_t columnFlagPresence = 4; // the 1-byte presence column of a std::optional constexpr std::string_view KindName(Kind kind) noexcept { switch (kind) { case Kind::Flags: return "flags"; case Kind::Bool: return "bool"; case Kind::Int8: return "i8"; case Kind::Int16: return "i16"; case Kind::Int32: return "i32"; case Kind::Int64: return "i64"; case Kind::UInt8: return "u8"; case Kind::UInt16: return "u16"; case Kind::UInt32: return "u32"; case Kind::UInt64: return "u64"; case Kind::Float32: return "f32"; case Kind::Float64: return "f64"; case Kind::TimePoint: return "timepoint"; case Kind::Duration: return "duration"; case Kind::String: return "string"; case Kind::Blob: return "blob"; case Kind::FixedBytes: return "bytes"; } return "?"; } struct Extent { std::uint64_t fileOffset; std::uint64_t length; }; static_assert(sizeof(Extent) == 16); // One of the two alternating commit slots. The writer fills the slot that // is not current, so a torn write can only damage the slot whose CRC then // fails, and the other slot still describes the previous commit. struct CommitRecord { std::uint64_t commitSeq; std::uint64_t rowCount; std::uint64_t liveCount; std::uint64_t heapUsed; std::uint64_t heapGarbage; std::array extentCount; std::uint64_t fileLength; std::uint64_t modifiedUnixNanos; std::uint64_t flags; std::array reserved; std::uint32_t crc32c; }; static_assert(sizeof(CommitRecord) == 512); static_assert(__builtin_offsetof(CommitRecord, extentCount) == 40); static_assert(__builtin_offsetof(CommitRecord, fileLength) == 56); static_assert(__builtin_offsetof(CommitRecord, flags) == 72); static_assert(__builtin_offsetof(CommitRecord, crc32c) == 508); struct FileHeader { std::array magic; std::uint16_t formatVersion; std::uint16_t headerPages; std::uint32_t formatFlags; std::uint32_t pageSize; std::uint32_t rowSize; std::uint16_t rowAlign; std::uint16_t columnCount; std::uint16_t orderedColumn; std::uint16_t regionCount; std::uint32_t schemaVersion; std::uint32_t reserved0; std::uint64_t schemaFingerprint; std::uint32_t schemaBlobLength; std::uint32_t minExtentBytes; std::uint64_t createdUnixNanos; std::array fileUuid; std::uint32_t migratedFromVersion; std::array reserved1; std::array, regionSlots> extents; std::array reserved2; std::array commits; }; static_assert(sizeof(FileHeader) == pageSize); static_assert(__builtin_offsetof(FileHeader, schemaVersion) == 32); static_assert(__builtin_offsetof(FileHeader, schemaFingerprint) == 40); static_assert(__builtin_offsetof(FileHeader, createdUnixNanos) == 56); static_assert(__builtin_offsetof(FileHeader, fileUuid) == 64); static_assert(__builtin_offsetof(FileHeader, migratedFromVersion) == 80); static_assert(__builtin_offsetof(FileHeader, extents) == 128); static_assert(__builtin_offsetof(FileHeader, reserved2) == 2176); static_assert(__builtin_offsetof(FileHeader, commits) == 3072); struct SchemaBlobHeader { std::array magic; std::uint16_t schemaFormat; std::uint16_t columnCount; std::uint32_t rowSize; std::uint32_t namePoolLength; }; static_assert(sizeof(SchemaBlobHeader) == 16); struct ColumnDescriptor { std::uint16_t offset; std::uint16_t size; Kind kind; std::uint8_t flags; std::uint16_t presenceOffset; std::uint16_t nameOffset; std::uint16_t nameLength; std::uint32_t paramNum; // chrono period numerator; element alignment for blobs; N for fixed bytes std::uint32_t paramDen; // chrono period denominator; element size for blobs std::uint32_t variant; // element kind for blobs; clock code for time points; 0 otherwise }; static_assert(sizeof(ColumnDescriptor) == 24); inline constexpr std::uint8_t maxKind = static_cast(Kind::FixedBytes); // The 16-byte handle of a variable-length column (Umbra / "German string"). // Bytes 4..16 are the first 12 bytes of the value, so a value of up to 12 // bytes is entirely inline and contiguous at (handle + 4). Longer values // keep their 4-byte prefix inline and store an offset into the heap region // where the whole value, prefix included, lives contiguously. struct Var { std::uint32_t length; std::uint32_t prefix; std::uint64_t tailOrOffset; }; static_assert(sizeof(Var) == 16 && alignof(Var) == 8); constexpr std::size_t KindSize(Kind kind) noexcept { switch (kind) { case Kind::Flags: case Kind::Bool: case Kind::Int8: case Kind::UInt8: return 1; case Kind::Int16: case Kind::UInt16: return 2; case Kind::Int32: case Kind::UInt32: case Kind::Float32: return 4; case Kind::Int64: case Kind::UInt64: case Kind::Float64: case Kind::TimePoint: case Kind::Duration: return 8; case Kind::String: case Kind::Blob: return sizeof(Var); case Kind::FixedBytes: return 0; // size comes from the descriptor } return 0; } // FNV-1a 64. Chosen over std::hash because the result is part of the file // format and must not change with the standard library. inline constexpr std::uint64_t fnvOffset = 14695981039346656037ull; inline constexpr std::uint64_t fnvPrime = 1099511628211ull; constexpr std::uint64_t Fnv1a(std::uint64_t hash, std::string_view bytes) noexcept { for (const char c : bytes) { hash ^= static_cast(c); hash *= fnvPrime; } return hash; } constexpr std::uint64_t Fnv1a(std::uint64_t hash, std::uint64_t value) noexcept { for (std::size_t i = 0; i < 8; i++) { hash ^= (value >> (8 * i)) & 0xFF; hash *= fnvPrime; } return hash; } // CRC-32C (Castagnoli), table driven. 508 bytes per commit is too little // for the hardware instruction to matter. namespace Detail { consteval std::array MakeCrcTable() { std::array table{}; for (std::uint32_t i = 0; i < 256; i++) { std::uint32_t c = i; for (std::int32_t k = 0; k < 8; k++) c = (c & 1) ? (0x82F63B78u ^ (c >> 1)) : (c >> 1); table[i] = c; } return table; } inline constexpr std::array crcTable = MakeCrcTable(); } constexpr std::uint32_t Crc32c(std::span bytes) noexcept { std::uint32_t crc = 0xFFFFFFFFu; for (const std::byte b : bytes) crc = Detail::crcTable[(crc ^ static_cast(b)) & 0xFF] ^ (crc >> 8); return crc ^ 0xFFFFFFFFu; } inline std::uint32_t CommitCrc(const CommitRecord& record) noexcept { return Crc32c(std::as_bytes(std::span(&record, 1)).first(__builtin_offsetof(CommitRecord, crc32c))); } inline bool CommitValid(const CommitRecord& record) noexcept { return record.commitSeq != 0 && CommitCrc(record) == record.crc32c; } // A parsed schema blob: what the file says its columns are. Column 0 is // always the flags byte at offset 0. struct SchemaInfo { std::vector columns; std::string namePool; std::uint32_t rowSize = 0; std::uint16_t rowAlign = 1; std::string_view Name(std::size_t column) const noexcept { const ColumnDescriptor& d = columns[column]; return std::string_view(namePool).substr(d.nameOffset, d.nameLength); } bool HasHeap() const noexcept { return std::ranges::any_of(columns, [](const ColumnDescriptor& d) { return d.kind == Kind::String || d.kind == Kind::Blob; }); } std::vector Serialize() const { std::vector out(sizeof(SchemaBlobHeader) + columns.size() * sizeof(ColumnDescriptor) + namePool.size()); const SchemaBlobHeader header{ schemaMagic, 1, static_cast(columns.size()), rowSize, static_cast(namePool.size()) }; std::memcpy(out.data(), &header, sizeof(header)); std::memcpy(out.data() + sizeof(header), columns.data(), columns.size() * sizeof(ColumnDescriptor)); std::memcpy(out.data() + sizeof(header) + columns.size() * sizeof(ColumnDescriptor), namePool.data(), namePool.size()); return out; } // Returns false when the bytes are not a well-formed blob. Every field // of every descriptor is range-checked here, because the engine and // DynamicRow index rows with them without further checks. static bool Parse(std::span bytes, std::uint16_t rowAlign, SchemaInfo& out) { if (bytes.size() < sizeof(SchemaBlobHeader)) return false; SchemaBlobHeader header; std::memcpy(&header, bytes.data(), sizeof(header)); if (header.magic != schemaMagic || header.schemaFormat != 1 || header.columnCount == 0 || header.rowSize == 0) return false; const std::size_t descriptorBytes = std::size_t(header.columnCount) * sizeof(ColumnDescriptor); if (bytes.size() < sizeof(header) + descriptorBytes + header.namePoolLength) return false; out.columns.resize(header.columnCount); std::memcpy(out.columns.data(), bytes.data() + sizeof(header), descriptorBytes); out.namePool.assign(reinterpret_cast(bytes.data() + sizeof(header) + descriptorBytes), header.namePoolLength); out.rowSize = header.rowSize; out.rowAlign = rowAlign; for (std::size_t i = 0; i < out.columns.size(); i++) { const ColumnDescriptor& d = out.columns[i]; if (static_cast(d.kind) > maxKind) return false; if (std::size_t(d.nameOffset) + d.nameLength > out.namePool.size()) return false; if (d.size == 0 || std::size_t(d.offset) + d.size > out.rowSize) return false; if (d.kind != Kind::FixedBytes && KindSize(d.kind) != d.size) return false; if (d.presenceOffset != noColumn && d.presenceOffset >= out.rowSize) return false; if ((d.flags & columnFlagOptional) != 0 && d.presenceOffset == noColumn) return false; if (i == 0 && (d.kind != Kind::Flags || d.offset != 0)) return false; if (i != 0 && d.kind == Kind::Flags) return false; } return true; } // Canonical hash of the layout; the same function runs at compile time // on Layout so the two sides agree byte for byte. The ordered // column is part of it: changing `orderedBy` changes what the file // promises about its rows. std::uint64_t Fingerprint(std::uint16_t orderedColumn) const noexcept { std::uint64_t hash = Fnv1a(fnvOffset, "crafter-db-v1"); hash = Fnv1a(hash, rowSize); hash = Fnv1a(hash, rowAlign); hash = Fnv1a(hash, orderedColumn); for (std::size_t i = 0; i < columns.size(); i++) { const ColumnDescriptor& d = columns[i]; hash = Fnv1a(hash, Name(i)); hash = Fnv1a(hash, static_cast(d.kind)); hash = Fnv1a(hash, d.offset); hash = Fnv1a(hash, d.size); hash = Fnv1a(hash, d.flags); hash = Fnv1a(hash, d.paramNum); hash = Fnv1a(hash, d.paramDen); hash = Fnv1a(hash, d.variant); } return hash; } }; }