-
Notifications
You must be signed in to change notification settings - Fork 8
Architecture
src/
├── main.rs — binary entry point (interactive REPL)
├── lib.rs — public API exports
├── db.rs — public embedded API: Minigraf, OpenOptions, WriteTransaction; register_aggregate / register_predicate for UDFs; prepare(query_str) -> PreparedQuery
├── repl.rs — interactive Datalog REPL console
├── temporal.rs — UTC timestamp parsing (avoids chrono CVE GHSA-wcg3-cvx6-7396)
├── wal.rs — write-ahead log: WalWriter, WalReader, CRC32 entries
├── error.rs — structured error codes: MinigrafError, ErrorCategory, ErrorCode registry (PRS/QRY/STG/WAL/API/INT); matches docs/ERROR_REFERENCE.md
├── browser/ — browser WASM backend (`browser` feature)
│ ├── buffer.rs — BrowserBufferBackend: in-memory pages with dirty-page tracking
│ └── indexeddb.rs — IndexedDB persistence for the browser backend
├── graph/
│ ├── types.rs — Fact, Value, EntityId, Attribute, VALID_TIME_FOREVER
│ └── storage.rs — FactStorage: in-memory EAV store with temporal query methods
├── query/datalog/
│ ├── parser.rs — EDN/Datalog parser (incl. not / not-join / expr / window clauses with safety checks)
│ ├── executor.rs — query executor with temporal filtering, not/not-join post-filters, eval_expr/is_truthy, apply_post_processing (aggregation + window)
│ ├── functions.rs — FunctionRegistry: string-keyed aggregate/window/predicate registry; AggregateDesc, AggState, WindowOps; AggImpl discriminator (Builtin(WindowOps) vs Udf(UdfOps)); UdfOps (type-erased init/step/finalise closures via Box<dyn Any + Send>); PredicateDesc (Arc<dyn Fn(&Value) -> bool + Send + Sync>); register_aggregate / register_predicate / register_builtin_aggregate; all built-in aggregates registered at startup
│ ├── matcher.rs — pattern matching engine with variable unification
│ ├── magic_sets.rs — magic-sets rewriting for demand-driven recursive rules (not applied to rules with not/not-join)
│ ├── evaluator.rs — RecursiveEvaluator + StratifiedEvaluator + evaluate_not_join + apply_expr_clauses_in_evaluator
│ ├── stratification.rs — DependencyGraph, stratify(): negative edges + cycle detection
│ ├── rules.rs — RuleRegistry: thread-safe rule management; stratify() on register
│ ├── optimizer.rs — selectivity-based query plan optimizer (disabled under wasm); Expr clauses passed through unchanged
│ ├── prepared.rs — PreparedQuery: parse-once/execute-many with named $slot bind slots; BindValue enum (Entity/Val/TxCount/Timestamp/AnyValidTime); prepare_query(), substitute(); 19 unit tests
│ └── types.rs — EdnValue (incl. BindSlot), Pattern, DatalogQuery, AsOf (incl. Slot), ValidAt (incl. Slot), WhereClause, BinOp, UnaryOp, Expr (incl. Slot), WindowFunc, Order, WindowSpec, FindSpec::Window; WindowFunc::Udf(String) and UnaryOp::Udf(String) variants for user-defined functions (resolved at runtime)
└── storage/
├── mod.rs — StorageBackend trait, FileHeader v7 (v8 from v3.0.0), CommittedFactReader / CommittedIndexReader traits
├── persistent_facts.rs — PersistentFactStorage: v7 save/load, auto-migration v1–v6→v7, CommittedFactLoaderImpl (v3.0.0: v8 save/load, v7→v8 migration only)
├── index.rs — EAVT/AEVT/AVET/VAET key types, FactRef, encode_value
├── btree.rs — legacy paged-blob B+tree (v5 migration only; removed in v3.0.0)
├── btree_v6.rs — on-disk B+tree: build_btree, OnDiskIndexReader, MutexStorageBackend
├── cache.rs — LRU page cache: approximate-LRU, read-lock on hits
├── packed_pages.rs — packed fact page format (~25 facts/4KB), MAX_FACT_BYTES
└── backend/
├── file.rs — FileBackend: single .graph file, cross-platform
├── memory.rs — MemoryBackend: in-memory backend for testing
└── fault_inject.rs — FaultInjectingBackend: injects I/O errors for durability tests (test builds only)
The bindings moved out of this repository in #231. Each one has its own repository and release pipeline, and is rebuilt when a new minigraf crate is published.
| Repository | Contents |
|---|---|
| minigraf-python | UniFFI bindings, minigraf on PyPI |
| minigraf-java | UniFFI bindings, io.github.project-minigraf:minigraf-jvm on Maven Central |
| minigraf-android | UniFFI bindings, io.github.project-minigraf:minigraf-android on Maven Central |
| minigraf-swift | UniFFI bindings, MinigrafKit xcframework via Swift Package Manager |
| minigraf-node | napi-rs bindings, minigraf on npm |
| minigraf-wasm |
@minigraf/browser and @minigraf/wasi on npm |
| minigraf-c | C FFI (cdylib + staticlib, minigraf.h via cbindgen) |
The unit of storage is a Fact — an Entity-Attribute-Value triple extended with bi-temporal metadata:
struct Fact {
entity: EntityId, // Uuid
attribute: Attribute, // String, e.g. ":person/name"
value: Value,
tx_id: TxId, // Uuid — transaction that asserted this fact
tx_count: u64, // monotonic transaction counter (used for :as-of queries)
valid_from: i64, // Unix ms — when fact became valid in the real world
valid_to: i64, // Unix ms — i64::MAX = open-ended (valid forever)
asserted: bool, // true = assert, false = retract
}
enum Value {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Ref(Uuid), // reference to another entity
Keyword(String), // e.g. ":status/active"
Null,
}VALID_TIME_FOREVER = i64::MAX is the sentinel for open-ended valid time.
┌─────────────────────────────────────┐
│ Minigraf / WriteTransaction (db.rs)│ ← public API
├─────────────────────────────────────┤
│ FactStorage (graph/storage.rs) │ ← in-memory EAV + index-driven scans
│ PersistentFactStorage │ ← persistence layer
├─────────────────────────────────────┤
│ PageCache (storage/cache.rs) │ ← LRU page cache (default 256 pages = 1MB)
├─────────────────────────────────────┤
│ StorageBackend trait │ ← platform-agnostic page I/O
│ FileBackend / MemoryBackend │
└─────────────────────────────────────┘
↕ WAL sidecar (wal.rs)
Pending (uncommitted) facts live in memory. Committed facts are stored in packed pages on disk and resolved on demand via the CommittedFactReader trait — no load-all at startup. Index lookups go through OnDiskIndexReader (Phase 6.5), which traverses B+tree pages via the LRU cache; index memory usage is O(cache_pages), not O(facts).
Four Datomic-style covering indexes are maintained for each committed fact:
| Index | Sort order | Best for |
|---|---|---|
| EAVT | entity → attribute → value → tx | entity lookups |
| AEVT | attribute → entity → value → tx | attribute scans |
| AVET | attribute → value → entity → tx | value equality lookups |
| VAET | value → attribute → entity → tx | reverse ref lookups |
Each index entry is a FactRef { page_id, slot_index } — a pointer to the fact's location in the packed pages. Values are encoded with sort-order-preserving byte representation so range scans work correctly.
Goes live in v3.0.0. Implemented on the long-lived
v3branch (#377). It is not onmain(#378 moved it off) and not in any v2.x release. v2.x keeps the v7 layout below.
In v2.x (format v7), EavtKey and AevtKey carry no value, so two values of one attribute written in the same transaction share a key. Reads can then return only one of them, and entity-bound and attribute-bound queries can disagree about which one (#371, #287). From v3.0.0 (format v8), each key identifies exactly one fact:
| Key | v7 fields (v2.x) | v8 fields (v3.0.0) |
|---|---|---|
| EAVT | entity, attribute, valid_from, valid_to, tx_count | … + value_bytes, asserted |
| AEVT | attribute, entity, valid_from, valid_to, tx_count | … + value_bytes, asserted |
| AVET | attribute, value_bytes, valid_from, valid_to, entity, tx_count | … + asserted |
| VAET | ref_target, attribute, valid_from, valid_to, source_entity, tx_count | … + asserted |
- The new fields are appended, so entity and attribute range scans keep the same order.
value_bytesisencode_value(&value). -
assertedkeeps an assertion and a retraction of the same value in the same transaction apart. - Keys are built only through per-key constructors (
EavtKey::from_fact,EavtKey::entity_start,AevtKey::attribute_start, …). Lookups scan from a start key and stop at the first non-matching key. - The query-time deduplication in
selective_fact_fetchalso uses the full fact identity(entity, attribute, tx_count, asserted, value, valid_from, valid_to).
The .graph file is page-based (4KB pages), endian-safe, cross-platform.
Page 0: FileHeader (84 bytes)
bytes 0.. 4 magic "MGRF"
bytes 4.. 8 version u32 LE (currently 7)
bytes 8..16 page_count u64 LE
bytes 16..24 node_count u64 LE (fact count)
bytes 24..32 last_checkpointed_tx_count u64 LE
bytes 32..40 eavt_root_page u64 LE (B+tree root for each covering index)
bytes 40..48 aevt_root_page u64 LE
bytes 48..56 avet_root_page u64 LE
bytes 56..64 vaet_root_page u64 LE
bytes 64..68 index_checksum u32 LE (CRC32 of committed fact pages)
byte 68 fact_page_format u8 (0x02 = packed)
bytes 69..72 _padding [u8; 3]
bytes 72..80 fact_page_count u64 LE (new in v6, retained in v7)
bytes 80..84 header_checksum u32 LE (new in v7 — CRC32 of header bytes 0..80)
Pages 1+: Packed fact data pages (page_type = 0x02)
12-byte page header:
byte 0 page_type (0x02)
byte 1 _reserved (0x00)
bytes 2..4 record_count u16 LE
bytes 4..12 next_page u64 LE (0 = no overflow)
Record directory: record_count × 4 bytes
per entry: offset u16 LE | length u16 LE
Record data: variable-length postcard-serialised Facts
(written end-to-start within the page)
Index pages (after fact pages): proper on-disk B+tree nodes (btree_v6.rs)
Internal node page: sorted keys + child page IDs
Leaf node page: sorted (key, FactRef) pairs + next_leaf pointer
Each B+tree node is exactly one 4KB page
Serialisation: facts use postcard — lightweight, embedded-focused, endian-safe.
Migration: from_bytes auto-migrates v1/v2/v3/v4/v5/v6 headers on open. v6 databases migrate to v7 on first checkpoint (header_checksum field added).
Not in any v2.x release. Implemented on the
v3branch (#377) for the v3.0.0 milestone. Until v3.0.0 ships, the current format is v7 as described above.
-
Layout: same 84-byte header and page layout as v7. Only the
versionfield (8) and the index key encoding (see Index key layout) differ. Fact pages are byte-identical. -
Supported formats: v3.0.0 reads v7 and v8 only (
MIN_FORMAT_VERSION = 7,FORMAT_VERSION = 8). -
v7 → v8 migration:
- Automatic and one-way. On first open, a v7 file (written by v2.x) always has all four B+tree indexes rebuilt from its fact pages, then gets a v8 header.
- Crash safety: index pages are synced before the header is written, so a crash mid-rebuild leaves a v7 header and the next open rebuilds again.
- The transaction counter never moves backwards; it is floored at the header's
last_checkpointed_tx_count. - A migrated file cannot be opened by v2.x (
STG-006).
-
v1–v6 are no longer readable:
- They fail with
STG-028before any page is touched, and the file is left unmodified. - To upgrade, open the file once with Minigraf v2.x (which migrates it to v7), then open it with v3.0.0.
- The v1→v2, one-per-page and v5→v6 migrations and
btree.rsare removed.
- They fail with
-
Error codes:
-
STG-003andSTG-004(pre-v7 header sizes) are deprecated: no longer emitted, but still registered and documented, and never reused. -
STG-005now reads "Invalid header: expected 84 bytes, got {}";STG-006reports "supported: 7-{}".
-
- Cost: index entries now carry value bytes. A 10,000-fact file is about 12% larger. Short benchmarks on insert, query, checkpoint and open ran 4–14% slower.
The WAL sidecar (<db>.wal) is present whenever there are uncommitted writes. It is replayed on open and deleted on checkpoint.
WAL file layout:
Header: magic "MWAL", version u32
Entries (repeated):
checksum u32 — CRC32 of the rest of the entry
tx_count u64 — transaction counter
num_facts u64 — number of facts in this entry
[ len u32 | postcard-bytes ]×num_facts
CRC32-protected entries ensure partial writes (from crashes) are safely discarded. Every WAL write is followed by a flush to disk, controlled by OpenOptions::synchronous (see Performance Tuning):
-
SyncMode::Full(default) —fdatasyncafter every entry. Matches Minigraf's original always-fsync behavior; every committedtransact/retractis durable immediately. -
SyncMode::Normal— no per-write flush. Entries are stillwrite_all()'d (safe across an ordinary process crash) but not forced to disk until the next checkpoint (auto-threshold, explicitcheckpoint(), or clean close). Data written since the last checkpoint is lost only on OS crash or power loss, not process death. Intended for bulk loaders that can safely re-run from a checkpoint watermark.
checkpoint() itself always fsyncs the main .graph file regardless of synchronous — it remains the hard durability boundary in both modes.
-
Parse — EDN string →
DatalogQuery;not/not-join/Exprclauses safety-checked at this stage; regex patterns inmatches?validated at parse time -
Plan —
optimizer.rsselects an index hint and reorders join clauses by selectivity;Exprclauses are passed through unchanged (not reordered — ordering guaranteed by safety check) -
Execute —
executor.rsiterates patterns, resolvesFactRefs via page cache, applies temporal filter:- Step 1: tx-time filter (
:as-ofcounter or timestamp) - Step 2: net-assertion filter — per
(entity, attribute, value)triple, keep only the latesttx_count; discard if that record is a retraction (asserted = false) - Step 3: valid-time filter (
:valid-ator:any-valid-time) - Step 4: not / not-join post-filter — applied per candidate binding after pattern matching
- Step 5:
Exprclause evaluation (apply_expr_clauses) — filter predicates drop non-truthy rows; arithmetic bindings extend the binding with the result value; type mismatches and div/0 silently drop the row - Step 6:
apply_post_processing— aggregates collapse rows (grouping by plain-variable:findspecs viaFunctionRegistry); window functions annotate per-row (sort within partition by:order-bykey, walk accumulating window state, emit one output row per input row)
- Step 1: tx-time filter (
-
Evaluate rules —
StratifiedEvaluatorstratifies rules; for each stratum:- Positive rules evaluated via
RecursiveEvaluator(semi-naive fixed-point iteration) - Mixed rules (containing
not/not-join/Expr) run positive patterns first, then apply negation filters and expr clauses per binding
- Positive rules evaluated via
register_rule calls stratify() after adding each rule. If the new rule creates a negative cycle in the dependency graph, stratify() returns Err and the rule is not registered. Non-recursive negation is always safe.
A .graph file is guarded by a kernel file lock taken on the file itself
(std::fs::File::try_lock: flock on Unix, LockFileEx on Windows). The lock
is released by the kernel whenever the holding process exits, however it exits,
so a crashed holder never leaves the database unopenable. Because flock and
OFD locks attach to the open file description rather than the process, a second
open within one process is refused too.
There is no lock file. A .graph.lock sidecar left behind by versions before
2.0 is ignored and never deleted, since a still-running old process may depend
on it. Running mixed versions against one file is not supported.
On a filesystem that cannot lock at all, open() fails rather than proceeding
unprotected. OpenOptions::allow_unlocked(true) overrides this, and accepts the
corruption risk that comes with it.
On Windows these locks are mandatory rather than advisory, and they exclude
every handle but the one holding them — including another handle in the same
process. While a database is open you cannot read its .graph file through a
second handle, your own included; the attempt fails with os error 33, "another
process has locked a portion of the file", even when that process is you.
Close the database first. On Unix the lock is advisory and such a read
succeeds.
A cross-process WouldBlock is retried with bounded backoff (10 attempts,
5ms doubling, capped at 50ms, ~375ms total) before being reported as a real
conflict, to ride out the transient window where a forked-but-not-yet-exec'd
subprocess holds a duplicate of someone else's lock — the same class of
problem SQLite's busy_timeout solves. A same-process conflict is not
retried; it fails immediately, since waiting could never help.
One handle per file, per process. A second open on a file this process
already has open is refused, naming the same-process case. This matters because
each FileBackend caches its own header.page_count, allocates new pages from
that count, and bounds-checks read_page against it — two handles on one file
give two page tables that diverge, which surfaces as
Page N out of bounds (total pages: M) and, past that, structural corruption.
Minigraf is cheap to clone and all clones share one database, so cloning the
existing handle is always the right move (#304).
- Concurrent reads via
Arc<RwLock<FactStorage>> - Exclusive write via
Mutex<WriteTransaction> - One live handle per file per process, enforced by the kernel file lock (see File Locking above)
- Rule registry is independently
Arc<RwLock<RuleRegistry>> - Function registry (
FunctionRegistry) isArc<RwLock<FunctionRegistry>>— shared between all query executions; built-in aggregates registered at startup; user-defined aggregates and predicates registered viaregister_aggregate/register_predicate(Phase 7.7b) - Page cache uses read-lock on hits, write-lock only on misses — minimises contention for read-heavy workloads
-
BrowserDb(wasm32-unknown-unknown) runs single-threaded — allArc/RwLock/Mutexcalls compile as single-threaded stubs under thebrowserfeature; no WASM thread support -
PreparedQueryholdsArcclones ofFactStorage,RuleRegistry, andFunctionRegistry— eachexecute()call re-reads live store state (new facts visible) while the query plan is reused