All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Minigraf::repl()factory method replaces directRepl::new(FactStorage)constructor — users calldb.repl().run()instead- All internal types narrowed to
pub(crate):FactStorage,PersistentFactStorage,FileHeader,StorageBackend,DatalogExecutor,PatternMatcher,Fact,TxId,VALID_TIME_FOREVER,Wal, and all related internals Minigraf::inner_fact_storage()removed (was unused)
Minigraf::repl(&self) -> Repl<'_>— constructs an interactive REPL session;Replnow borrows&Minigraffor lifetime safety- Full rustdoc on all public API items with
# Examplesdoctests [package.metadata.docs.rs]inCargo.toml— docs.rs builds withall-features = true#![warn(missing_docs)]— enforces documentation coverage going forward- crates.io and docs.rs badges in
README.md - Installation section in
README.md(cargo add minigraf/[dependencies]block) - macOS and Windows added to CI test matrix (
rust.yml) - Strict
cargo clippy -- -D warningsstep inrust-clippy.yml
- Bare
.unwrap()in library code replaced with.expect("lock poisoned")(RwLock operations incache.rs,evaluator.rs) and.expect("WAL not initialized")(db.rs) FileHeader::to_bytesnow takesselfby value (clippywrong_self_convention)- Broken intra-doc link
[Repl::run]indb.rsfixed to[crate::repl::Repl::run]
788 tests.
Minigraf::prepare(query_str) -> Result<PreparedQuery>— parse and plan a query once, returning aPreparedQuerythat can be executed many times with different bind valuesPreparedQuery::execute(bindings: &[(&str, BindValue)]) -> Result<QueryResult>— substitute named$slottokens and run against the current fact store state; plan is reused on each callBindValueenum —Entity(Uuid),Val(Value),TxCount(u64),Timestamp(i64),AnyValidTime; each variant is permitted only in the appropriate bind-slot position$identifierbind slot tokens in parser — accepted in entity position, value position,:as-of, and:valid-at; attribute position is intentionally rejected at prepare timeEdnValue::BindSlot(String),AsOf::Slot(String),ValidAt::Slot(String),Expr::Slot(String)AST variants (parse-only; panic at runtime if unsubstituted)BindValueandPreparedQueryre-exported fromlib.rs(public API surface)tests/prepared_statements_test.rs— 17 integration tests covering all slot positions, combined temporal + entity parameterisation, plan reuse, and all error paths
src/query/datalog/prepared.rs— new module:prepare_query(), substitution logic, 19 unit tests; manualDebugimpl forPreparedQuery(avoidsFactStorage: Debugbound)- Panic guards (no slot-name interpolation) in
executor.rs(4 sites) andstorage.rs(1 site) for unsubstituted slot variants; CodeQL-safe (no user-controlled string in panic message)
db.execute(str)string API — no breaking change- Executor, optimizer, matcher — no changes required
Minigraf::register_aggregate(name, init, step, finalise)— register a custom aggregate function usable in both:findgrouping and:over(window) clausesMinigraf::register_predicate(name, f)— register a single-argument filter predicate usable in[(name? ?var)]:whereclausesFunctionRegistry::register_aggregate_desc/register_predicate_desc(internal API)WindowFunc::Udf(String)andUnaryOp::Udf(String)AST variants for runtime-resolved functionsUdfOps,AggImpl,PredicateDesctypes infunctions.rs
AggregateDescnow usesAggImpldiscriminator instead ofwindow_compatible+window_opsapply_expr_clausesnow returnsResult<Vec<Binding>>and accepts&FunctionRegistryeval_expracceptsOption<&FunctionRegistry>for UDF predicate resolutionWindowSpec::func_name()now returnsStringinstead of&'static str- Parser emits
Udfvariants for unknown names instead of erroring (runtime validation)
- Window functions in Datalog
:findclause:(sum ?v :over (...)),(count ?v :over (...)),(min ?v :over (...)),(max ?v :over (...)),(avg ?v :over (...)),(rank :over (...)),(row-number :over (...))with unbounded-preceding (cumulative from partition start to current row) frame :partition-by ?varoptional clause: absent means whole result set is one partition:order-by ?varrequired in every:overclause;:descoptional (default ascending)FunctionRegistry(src/query/datalog/functions.rs): string-keyed registry of aggregate descriptors; all built-in aggregates migrated into it;window_ops(init/step/finalise) on window-compatible entries;is_builtinflag separates built-ins from future UDFs- Mixed queries: regular aggregates and window functions may coexist in the same
:findclause; aggregates collapse rows first, windows annotate over collapsed rows AggregateDesc,AggState,WindowOpstypes infunctions.rsWindowFunc,Order,WindowSpec,FindSpec::Windowtypes intypes.rstests/window_functions_test.rs: 12 integration tests (cumulative sum, running count/min/avg, rank with ties, row-number, partition-by, desc ordering, mixed aggregate+window, single-row and empty-result edge cases, lag/lead parse rejection)
FindSpec::Aggregate { func }: type offuncchanged fromAggFuncenum toString; dispatch goes throughFunctionRegistry— internal change, no public API impactAggFuncenum removed fromtypes.rs; all aggregate dispatch centralised infunctions.rsapply_aggregationandapply_agg_funcremoved fromexecutor.rs; replaced byapply_post_processing+ helpers
707 tests (unit + integration + doc)
- Temporal pseudo-attributes:
:db/valid-from,:db/valid-to,:db/tx-count,:db/tx-id, and:db/valid-atare now first-class bindable values in Datalog:wherepatterns PseudoAttrenum andAttributeSpecwrapper type intypes.rs— clean type-safe representation for real vs. pseudo attributes inPatternparse_query_patterninparser.rs— detects:db/*keywords in the attribute position; rejects them in entity/value positions (parse error)PatternMatcher::from_slice_with_valid_atconstructor — passes query-levelvalid_atinto the matcher- Hard-error guard in executor: per-fact pseudo-attrs (
:db/valid-from,:db/valid-to,:db/tx-count,:db/tx-id) require:any-valid-time; error message tells user exactly what to add :db/valid-atbinds the effective query timestamp: explicit:valid-at <ts>→Value::Integer(ts), no:valid-at→Value::Integer(now),:any-valid-time→Value::Null:any-valid-timenow accepted as a standalone top-level query keyword (previously required:valid-at :any-valid-timeform)tests/temporal_metadata_test.rs: 16 new integration tests covering time-interval range queries, time-point lookups, tx-time correlation,:db/valid-atsemantics, and all parse/runtime error guards
647 tests (438 unit + 209 integration)
tests/production_patterns_test.rs: 8 cross-feature integration tests combining not+as-of, not-join+count, count+not, count+valid-at, recursion+not, or+count, or+sum, count+as-of-sequencetests/error_handling_test.rs: 8 integration-level error-path tests covering runtime type errors (sum/string, sum/mixed, max/boolean), stratification errors (negative cycles), and parse safety errors (not-join unbound join var, or mismatched vars, aggregate unbound var)- Stream 3: ~109 unit tests for parser-unreachable branches and aggregation/arithmetic edge cases in
executor.rsandevaluator.rs cargo-llvm-covbranch coverage command documented inCONTRIBUTING.md- CI coverage enforcement:
cargo-tarpaulin --fail-under 75gates every PR; Codecov 75% threshold with 2% drop tolerance;fail_ci_if_error: true - Nightly
cargo-llvm-cov --branchworkflow: uploads LCOV to Codecov (branch-coverageflag) and attaches HTML artifact (30-day retention); also triggerable viaworkflow_dispatch - Codecov badge added to
README.md
- Branch coverage:
executor.rs~85.71% (from ~75%),evaluator.rs~89.29% (from ~73%) - Remaining uncovered branches: NaN-check defensive code not reachable via public API
- Total: 617 tests (424 unit + 187 integration + 6 doc)
or-with-negative-cycle: stratification does not currently detect negative cycles insideorbranches. Tracked via#[ignore]intests/error_handling_test.rs::or_negative_cycle_rejected.
filter_facts_for_querysnapshot fix — function now returnsArc<[Fact]>instead of a throwawayFactStorage, eliminating the O(N) four-BTreeMap index rebuild that occurred on every non-rules query call.execute_querypath constructs zeroFactStorageobjects.execute_query_with_rulesstill convertsArc<[Fact]>back toFactStorageforStratifiedEvaluator(deferred).- ~62–65% speedup on non-rules queries at 10K facts:
query/point_entity/10k22 ms → 8.6 ms;aggregation/count_scale/10k28 ms → 9.7 ms. - Evaluator loop:
accumulated_factscomputed once per iteration (was 4 separateget_asserted_facts()calls).
PatternMatcher::from_slice(Arc<[Fact]>)constructor — creates a matcher from an immutable fact snapshot without index reconstruction.
apply_or_clausesandevaluate_not_joinsignatures updated to acceptArc<[Fact]>instead of&FactStorage.- 6 new tests: 4 in
matcher.rs(unit), 2 inexecutor.rs(unit).
- Total: 568 tests passing (390 unit + 172 integration + 6 doc)
- Disjunction (
or/or-join): queries and rule bodies can now use(or branch1 branch2 ...)and(or-join [?v...] branch1 branch2 ...)where-clauses. Branches support all other clause types includingnot,not-join,Expr, and nestedor/or-join.(and ...)groups multiple clauses into a single branch. match_patterns_seededonPatternMatcherfor seeded branch evaluation.evaluate_branchandapply_or_clausesaspub(crate)helpers inexecutor.rs.
WhereClauseenum gainsOr(Vec<Vec<WhereClause>>)andOrJoin { join_vars, branches }variants.DependencyGraph::from_rulesrefactored with recursivecollect_clause_depshelper;Or/OrJoinbranches contribute positive dependency edges.- Rules with
or/or-joinin their bodies route to themixed_rulespath inStratifiedEvaluator.
BinOpenum (14 variants:Lt,Gt,Lte,Gte,Eq,Neq,Add,Sub,Mul,Div,StartsWith,EndsWith,Contains,Matches) intypes.rsUnaryOpenum (5 variants:StringQ,IntegerQ,FloatQ,BooleanQ,NilQ) intypes.rsExprenum (Var,Lit,BinOp,UnaryOp) — composable expression AST intypes.rsWhereClause::Expr { expr: Expr, binding: Option<String> }variant —None= filter,Some(var)= arithmetic bindingparse_expr_arg/parse_expr/parse_expr_clauseinparser.rs; dispatch at all 4 clause sites (query:where, rule body,notbody,not-joinbody)- Parse-time regex validation for
matches?patterns viaregex-lite; invalid patterns are rejected with a clear error check_expr_safety+check_expr_safety_with_boundinparser.rs— forward-pass safety check; recurses intonot/not-joinbodies; unboundExpr::Varreferences are rejected at parse timeouter_vars_from_clauseupdated forWhereClause::Expr— binding variable contributes to scope for subsequent clauseseval_expr,eval_binop,is_truthy,apply_expr_clausesinexecutor.rs— evaluate expression trees against a binding; type mismatches and div/0 silently drop the rowapply_expr_clauses_in_evaluatorinevaluator.rs— sibling helper for rule body andnot-joinevaluation pathsnot_body_matchesinexecutor.rsupdated to seed with outer binding for expr-onlynotbodiestests/predicate_expr_test.rs— 28 integration tests covering all operators, silent-drop semantics, integer division, NaN, int/float promotion, string predicates, regex, expr innotbody, expr in rule body, bi-temporal + expr, arithmetic into aggregate
- Comparison operators (
<,>,<=,>=) require both operands to be numeric (IntegerorFloat); type mismatch → row dropped =/!=use structural equality onValue— type mismatch returnsfalse/true, not an error- Integer
+Floatpromotes toFloat; integer division truncates; division by zero → row dropped; NaN result → row dropped is_truthy:Boolean(true)→ true; non-zeroIntegerorFloat→ true; everything else (includingKeyword,Ref,Null, zero, empty string,Boolean(false),-0.0) → falsematches?pattern compiled at eval time viaregex-lite; pattern must be a string literal validated at parse time
- Added
tests/predicate_expr_test.rs(28 integration tests) - Total: 527 tests passing (365 unit + 156 integration + 6 doc)
- Aggregation in
:findclause:count,count-distinct,sum,sum-distinct,min,max :withgrouping clause — variables that participate in grouping but are excluded from output rowsAggFuncenum andFindSpecenum insrc/query/datalog/types.rs;DatalogQuery.findmigrated fromVec<String>toVec<FindSpec>;DatalogQuery.with_vars: Vec<String>field addedapply_aggregationpost-processing step inexecutor.rs— runs after binding collection when any aggregate is presentextract_variableshelper inexecutor.rs— non-aggregate extraction path (replaces inline loops)apply_agg_funcandvalue_type_namehelpers inexecutor.rsparse_aggregatehelper inparser.rs;:findarm extended to acceptEdnValue::List(aggregate expressions);:withkeyword arm added- Parse-time validation: aggregate variables must be bound in
:where;:withwithout any aggregate is rejected tests/aggregation_test.rs— 24 integration tests covering all aggregates,:with, rules, negation, temporal filters
count/count-distinctwith no grouping vars on zero bindings →[[0]](SQL behavior)- All other aggregates on zero bindings → empty result set
- All aggregates skip
Value::Nullsilently (SQL behavior) - Type mismatches (e.g.
sumonString) fail fast with a runtime error min/maxon mixedInteger/Floatis a runtime error:with ?vadds?vto the grouping key without adding it to output columns
- Added
tests/aggregation_test.rs(24 integration tests) - Total: 461 tests passing (327 unit + 128 integration + 6 doc)
src/query/datalog/stratification.rs—DependencyGraphandstratify(): analyse rule dependency graphs at registration time; programs with negative cycles are rejected with a clear errorWhereClause::Not(Vec<WhereClause>)andWhereClause::NotJoin { join_vars, clauses }variants intypes.rs; all exhaustive matches updated(not clause…)in:whereand rule bodies — stratified negation where all body variables must be pre-bound by outer clauses(not-join [?v…] clause…)— existentially-quantified negation with explicit join-variable declaration; body variables not injoin_varsare fresh/unbound- Safety check at parse time: every
notbody variable must be bound by an outer clause; everyjoin_varsvariable innot-joinmust be bound by an outer clause - Nesting constraint:
not-joincannot appear insidenotor anothernot-join— rejected at parse time StratifiedEvaluatorinevaluator.rs: stratifies rules, runs positive rules first, then appliesnot/not-joinfilters per binding for mixed rulesevaluate_not_joinfree function inevaluator.rs: builds 8BD0 partial binding fromjoin_vars, convertsPatternandRuleInvocationbody clauses to patterns, runsPatternMatcher; returnstrueif body is satisfiable (reject outer binding)rule_invocation_to_patternextracted aspub(super)free function fromRecursiveEvaluator- Two not-post-filter sites in
executor.rsnow handle bothNotandNotJoinviaevaluate_not_join tests/negation_test.rs— 10 integration tests fornot(Phase 7.1a): basic absence, multi-clause, rule body, time-travel, negative cycle rejectiontests/not_join_test.rs— 14 integration tests fornot-join(Phase 7.1b): basic exclusion, multiple join vars, multi-clause body, rule body,:as-of,:valid-at, negative cycle at registration,not+not-joincoexistence,RuleInvocationin body end-to-end
Rule.bodychanged fromVec<EdnValue>toVec<WhereClause>to support negation clauses alongside patternsexecutor.rsexecute_query_with_rulesnow delegates toStratifiedEvaluatorinstead ofRecursiveEvaluatordirectlyrules.rsregister_rulerunsstratify()after each registration; returnsErron negative cycle (rules are not registered on error)
src/storage/btree_v6.rs— proper on-disk B+tree for all four covering indexes (EAVT, AEVT, AVET, VAET); each B+tree node is one 4KB page (internal + leaf), withbuild_btreefor bulk-load andrange_scanfor leaf-chain traversalOnDiskIndexReaderstruct +CommittedIndexReadertrait — page-cache-backed index lookup replacing the full in-memory BTreeMap; index memory usage is now O(cache_pages), not O(facts)MutexStorageBackend<B>adapter — holds backend mutex only for the duration of a singleread_pagecall on a cache miss; cache-warm pages require no lock, enabling concurrent range scans to proceed in paralleltests/btree_v6_test.rs— 8 integration tests covering B+tree insert/range-scan, multi-page leaf chains, concurrent scan correctness with Barrier-synchronised threads, and v5→v6 migration roundtriptest_concurrent_range_scans_correctnessunit test inbtree_v6.rs— verifies all 8 concurrent threads return identical non-empty scan resultsbench_concurrent_btree_scanCriterion benchmark — measures wall-clock latency at 2/4/8 concurrent EAVT range scans; results updated inBENCHMARKS.mdFileHeaderv6 (80 bytes): addsfact_page_count u64field at bytes 72–80; automatic v5→v6 migration on first checkpoint
FORMAT_VERSIONbumped 5→6; v5 databases auto-migrated on first saveBENCHMARKS.mdupdated with v6 open/memory improvements, concurrent B+tree scan results, heaptrack v6 numbers, and a "How to read these numbers" methodology sectionREADME.mdandBENCHMARKS.md: performance table updated to reflect v6 open-time reduction (~2.4×) and peak-heap reduction (~21%)
- Concurrent B+tree range scans no longer serialise on cache-warm pages —
4→8 threadscaling ratio improved from ~2.2× to ~1.9×
BENCHMARKS.md— full Criterion benchmark results at 1K/10K/100K/1M facts with machine spec, HTML report references, and heaptrack memory profilesexamples/memory_profile.rs— heaptrack profiling binary; accepts fact count as positional argCargo.tomlmetadata:repository,keywords,categories,readme,documentationfields- Memory profile table in
README.md"Performance" section
README.mdPerformance section now links toBENCHMARKS.mdfor full benchmark details- Phase badge and status text updated to reflect Phase 6.4b completion
- crates.io publish deferred to Phase 7.8 (API cleanup + publish prep; file format v6 now complete)
- Dead
clapdependency from[dependencies]—clapwas listed but never imported in library or binary code
- Retraction semantics in Datalog queries:
filter_facts_for_queryStep 2 now computes the net view per(entity, attribute, value)triple vianet_asserted_facts(). Previously, retracted facts continued to appear in query results because the original assertion record remained in the append-only log. Now, for each EAV triple in the tx window, only the record with the highesttx_countis considered — if it is a retraction, the triple is excluded from results. - Oversized facts are now rejected early in
db.rs(check_fact_sizes) before any WAL write, using theMAX_FACT_BYTESconstant (4 080 bytes) exported frompacked_pages.rs. Previously, oversized facts could cause a panic deep in the page-packing path.
net_asserted_facts(facts: Vec<Fact>) -> Vec<Fact>helper insrc/graph/storage.rs: groups facts by EAV triple, keeps the record with the highesttx_count, and discards the triple if that record is a retraction. Used by bothexecutor.rsandstorage.rs.check_fact_sizes(facts: &[Fact])insrc/db.rs: validates all facts againstMAX_FACT_BYTESand returns a descriptiveErrbefore writing to the WAL.MAX_FACT_BYTES: usizeconstant insrc/storage/packed_pages.rs:PAGE_SIZE - PACKED_HEADER_SIZE - 4= 4 080 bytes.tests/retraction_test.rs— 7 integration tests covering: assert/retract with no:as-of, as-of snapshot before/after retraction boundary, re-assert after retract,:any-valid-timewith retraction, recursive rule retraction visibility at and before the retraction boundary.tests/edge_cases_test.rs— 4 integration tests covering: oversized-fact file-backed error path,MAX_FACT_BYTESexact boundary (accepted),MAX_FACT_BYTES + 1(rejected), in-memory database has no size limit.
- Packed fact pages (
page_type = 0x02): ~25 facts per 4KB page, ~25× disk space reduction vs v4 - LRU page cache (
src/storage/cache.rs): configurable capacity (default 256 pages = 1MB) OpenOptions::page_cache_size(usize)— tune page cache capacityCommittedFactReadertrait: index-driven fact resolution via page cache (no startup load-all)- File format v5:
fact_page_formatheader field; auto-migration from v4 on first open - Page-based CRC32 checksum (v5): streams raw committed pages instead of all facts
PersistentFactStorage::new()takespage_cache_capacity: usizeas second argument- Committed facts no longer loaded into
Vec<Fact>at startup; only pending facts held in memory FactStorage::get_facts_by_entity,get_facts_by_attributeuse EAVT/AEVT index range scans
- v4 databases auto-migrated to v5 packed format on first open (no data loss)
- Four Datomic-style covering indexes (EAVT, AEVT, AVET, VAET) with bi-temporal keys (
valid_from,valid_toin all key tuples) FactRef { page_id: u64, slot_index: u16 }— forward-compatible disk location pointer (slot_index=0 in 6.1)- Canonical value encoding (
encode_value) with sort-order-preserving byte representation - B+tree page serialization for index persistence (
src/storage/btree.rs) FileHeaderv4 (72 bytes): addseavt_root_page,aevt_root_page,avet_root_page,vaet_root_page(4×8=32 bytes),index_checksum(u32), replacing thereservedfield- CRC32 sync check on open: index mismatch triggers automatic rebuild
FactStorage::replace_indexes()andindex_counts()for index lifecycle management- Query optimizer (
src/query/datalog/optimizer.rs):IndexHintenum,select_index(),plan()with selectivity-based join reordering - Join reordering skipped under
wasmfeature flag Cargo.toml[features]section withdefault = []andwasm = []- 6 integration tests in
tests/index_test.rsfor save/reload, bi-temporal, recursive rules regression
FactStorageinternal structure:FactData { facts, indexes }under singleArc<RwLock<FactData>>for consistent snapshotsPersistentFactStorage::save()writes index B+tree pages and updates header checksumPersistentFactStorage::load()performs sync check and fast-path index loadexecutor::execute_query()now callsoptimizer::plan()before pattern matching- File format version bumped 3→4; automatic v1/v2/v3→v4 migration on first save
FORMAT_VERSIONconstant updated to 4
- NaN values in
Value::Floatnow canonicalize to a single bit pattern in index encoding (deterministic sort order)
- Write-ahead log (WAL): fact-level sidecar
<db>.walwith CRC32-protected binary entries WriteTransactionAPI:begin_write()/commit()/rollback()for explicit ACID transactions- Crash recovery: WAL entries replayed on open; corrupt/partial entries discarded at first bad CRC32
- Checkpoint:
checkpoint()flushes WAL facts to.graphand deletes the WAL; auto-checkpoint on configurable threshold FileHeaderv3:last_checkpointed_tx_countfield (repurposes unusededge_countslot)FactStoragehelpers:get_all_facts(),restore_tx_counter(),allocate_tx_count()OpenOptionsbuilder:OpenOptions::new().path("db.graph").open()orMinigraf::in_memory()--file <path>CLI flag for the REPL binary- 41 new tests covering WAL, crash recovery, transactions, and checkpoint
src/minigraf.rsreplaced bysrc/db.rs—Minigraf,OpenOptions,WriteTransactionpublic API- File format version bumped 2→3; automatic v1/v2→v3 migration on first checkpoint
- REPL version string now tracks
CARGO_PKG_VERSIONautomatically
- WAL-before-apply ordering: facts are now applied to in-memory state only after the WAL entry is fsynced, ensuring crash safety for both implicit (
execute()) and explicit (WriteTransaction) write paths
- Bi-temporal support: every fact now carries transaction time (
tx_id,tx_count) and valid time (valid_from,valid_to) :as-of Nquery modifier for transaction time travel (counter or ISO 8601 timestamp):valid-at "date"query modifier for valid time point-in-time queries:valid-at :any-valid-timeto disable valid time filtering(transact {:valid-from ... :valid-to ...} [...])syntax for specifying valid time- Per-fact valid time override in transact (4-element fact vectors with metadata map)
- File format version 2 with automatic migration from version 1
- Breaking behaviour: queries without
:valid-atnow return only currently valid facts (valid_from <= now < valid_to). Existing Phase 3 databases are unaffected because all migrated facts havevalid_to = MAX. FactStorage::transact()now accepts an optionalTransactOptionsparameter
PersistentFactStorage::load()previously discarded originaltx_idwhen loading facts from disk, making time-travel queries on persisted databases incorrect
- Datalog core implementation with recursive rules
- Entity-Attribute-Value (EAV) data model
- Pattern matching with variable unification
- Semi-naive evaluation for recursive rules
- Transitive closure support with cycle handling
- Rule registry for rule management
- Persistent storage with postcard serialization
- REPL with multi-line command support and comments
- 123 comprehensive tests (94 unit + 26 integration + 3 doc)
- Replaced GQL-inspired syntax with Datalog EDN syntax
- Data model changed from property graph to EAV triples
- Query executor rewritten for Datalog pattern matching
- Persistent storage backend with
.graphfile format - StorageBackend trait for platform abstraction
- FileBackend implementation (4KB pages, cross-platform)
- MemoryBackend for testing
- PersistentGraphStorage layer for serialization
- Embedded API (
Minigraf::open(),Minigraf::execute()) - Auto-save on drop
- Graph storage now supports persistence
- Initial release
- In-memory property graph implementation
- Basic graph operations (nodes, edges, properties)
- Interactive REPL
- Thread-safe storage with
Arc<RwLock<>>