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.
-
OpenOptions::allow_overrides(#869, part of #862): the embedder lists, asOverridablevariants, the query keywords that may override the database's settings. The default,Overridable::DEFAULTS(MaxDerivedFacts,MaxResults), allows every keyword that could do so before, so no query changes. A query using a keyword the list leaves out is refused with the new API-025 before it runs, fromexecute,query,prepareandWriteTransaction::execute; the list replaces the defaults rather than adding to them, and is not stored in the file.BrowserDbtakes no options and uses the defaults.Overridableis#[non_exhaustive]. -
:query-memory-limit, limit bind slots andPreparedQuerylimit setters (#870, part of #862).:query-memory-limit NreplacesOpenOptions::query_memory_limitfor one query, up or down;0is allowed and lets only a query that holds no state run (QRY-012 otherwise). It is gated by the newOverridable::QueryMemoryLimit, which is not inOverridable::DEFAULTS, so a database refuses it with API-025 unless the embedder lists it;BrowserDbalways refuses it.:max-derived-facts,:max-resultsand:query-memory-limittake a$slotfilled withBindValue::Val(Value::Integer(n))(INT-014/INT-015 out of range as for a literal, API-024 for another type, API-022 when missing).PreparedQuery::with_max_derived_factsandwith_query_memory_limitreplace the database values for one statement; they are not gated, and an allowed keyword still overrides them. See docs/CONFIGURATION.md. The v3 binary-size gate rises from 1,234 to 1,240 KiB (+672 B measured here; the rest is room for #861). -
Per-transaction WAL sync mode and chunk size:
TxOptionsandMinigraf::begin_write_with(#871, part of #862).TxOptions(#[non_exhaustive]; fieldssynchronous: Option<SyncMode>andchunk_facts: Option<usize>, buildersnew,synchronous,chunk_facts) sets either option for oneWriteTransaction;Nonemeans the database's value, andbegin_write()isbegin_write_with(TxOptions::default()).synchronousdecides whether that transaction's WAL entry is synced, whateverOpenOptions::synchronousis, so a bulk load can commit withNormalon aFulldatabase; aFullcommit syncs the whole WAL, so earlierNormalentries become durable with it. It has no effect on a transaction that becomes large (a checkpoint, durable in both modes; its WAL guard header is always synced, a deviation from the #862 design recorded there) or on an in-memory database.chunk_factsreplacesOpenOptions::transaction_chunk_factsfor that transaction, with the same clamp (1..=1,000,000). The options are not stored in the file, not gated byallow_overrides, and apply to that transaction only.begin_write_withfails asbegin_writedoes (INT-001,API-014). The WAL writer no longer keeps a sync mode: each append is given its transaction's. See docs/DURABILITY.md for what survives a crash when modes are mixed. -
Copyable prepared queries and browser limit setters (#872, part of #862).
PreparedQueryisClone: a copy is an independent statement over the same database, so a limit setter applied to one leaves the other unchanged; language bindings use it to return a new prepared object from each setter.BrowserPreparedQuerygainswithMaxDerivedFacts(n),withQueryMemoryLimit(bytes)andwithMaxIterations(n), each returning a new prepared query that keeps the database open; a number at or above the platform'susize::MAXmeans no limit, and a negative, fractional or non-finite number throws[API-017].BrowserDbstill takes no open options.docs/CONFIGURATION.mdlists which settings every binding exposes. -
Configurable recursion round limit (#861, part of #862).
OpenOptions::max_iterationssets the most rounds one stratum of a rule query may run (default 1,000,000, see Breaking changes);:max-iterations Nor$slotreplaces it for one query, up or down (INT-012/013/014/015 as for:max-derived-facts), gated by the newOverridable::MaxIterations, which is inOverridable::DEFAULTS;PreparedQuery::with_max_iterationsreplaces it for one statement, ungated, and an allowed keyword still overrides it. Rounds are still counted per stratum. See docs/CONFIGURATION.md. -
Owned write transactions and browser transactions (#824).
Minigraf::begin_write_owned()andbegin_write_owned_with(TxOptions)return anOwnedWriteTransaction:execute,commitandrollbackwith the borrowedWriteTransaction's results and errors, but it isSendand'static, keeps the database open, and may begin on one thread and run, commit, roll back or be dropped on another. Dropping it open rolls it back. While it is open the calls aWriteTransactionrefuses areINT-001on the thread that began it (per database, whichever thread uses it meanwhile); other threads' writes, checkpoints and begins wait until it finishes, and their queries read the committed state without waiting. A user function in one of its statements that calls the database getsINT-001on whichever thread runs the statement. Every language binding's transaction is built on it.BrowserDb.beginWrite()returns aBrowserWriteTransactionwith a synchronousexecute, acommit()that applies the staged facts as one transaction and resolves after the IndexedDB flush, and arollback(); freeing it open rolls it back. While it is open,BrowserDb'sexecute,query,prepare,checkpoint,importGraph, a secondbeginWriteand a prepared query'sexecute/querythrow or reject with[INT-001]; a finished one'sexecuteandcommitare[API-018]. No new error codes; no file format change.
-
INT-001 text (#824):
a WriteTransaction is already in progress on this thread; use tx.execute() insteadnow readsa write transaction is already in progress on this thread; use tx.execute() instead, since owned and browser transactions raise it too. The code and the calls that raise it for a borrowed transaction are unchanged. -
INT-015 text (#870):
{} must be a positive integeris now{} must be a non-negative integer, because:query-memory-limitaccepts 0. The code and when:max-derived-facts/:max-resultsraise it are unchanged. -
INT-020 round-limit text (#861):
Max iterations (N) exceeded. Possible infinite recursion or cycle in rules.now readsMax iterations (N) exceeded. A stratum's rules were still deriving new facts after N rounds; raise the limit with :max-iterations N or OpenOptions::max_iterations.The code is unchanged. -
Ordinary average (#777):
(avg ?v)no longer raises PRS-027. Grouped and ungrouped queries use the same numeric population and arithmetic as windowavg: skip nil and nonnumeric values, sum exactly, round the total once to Float, then divide by the numeric count. Integer totals are not saturated. Results are Float, or nil for an existing group without numbers; no matching rows yields no rows. The total can overflow before division, and division rounds separately. Existing window behavior is unchanged. Retained exact-sum allocations for ordinarysumandavgare now charged to the query memory limit. -
Align ordinary aggregate value handling with windows (#789, v3.0.0).
sumandsum-distinctskip nil and nonnumeric arguments;min/maxcompare every non-nil type using the existing window total order, including mixed Integer/Float values. Count variants retain their non-nil counting and distinctness. Existing groups with no numeric sum arguments return Integer(0); all-nil min/max groups remain omitted, and no-match query conventions remain unchanged. INT-041/043/044 are deprecated for these built-ins. No v2 runtime backport, window behavior, ordered-group eligibility, or storage change.
-
Owned and browser transaction tests (#824):
tests/owned_tx_test.rschecks that an owned transaction leaves the database as a borrowed one given the same statements and options (commit, rollback, drop; small and large; file and in-memory, after reopening), the threads rules and the begin refusals; the fault matrix runs its large-transaction and mixed-sync-mode cases through the owned transaction too.src/browser/tx_tests.rs(wasm-bindgen) checks the browser transaction against the native one over fixed statement sequences, its IndexedDB commit, rollback, drop and failed-flush paths, and the[INT-001]/[API-018]rules. -
Acceptance execution gaps (#837): nonlinear and multistratum recursive scaling, custom transaction chunk/clamp bounds, mandatory Linux RSS assertions, and exact named-test/output verification in the nightly smoke route. The storage bound now compares exact bytes rather than a truncated ratio. No engine, public API or format change. See bounds and validation.
-
Configuration reference (#869): docs/CONFIGURATION.md lists every
OpenOptionssetting, where else it can be changed, and howallow_overridesgates query text. -
Heavy WAL entry replay test (#865): a nightly acceptance test writes one WAL entry of 1,000,000 facts, reopens without a checkpoint and checks every replayed fact through entity, attribute and attribute-value lookups, then checkpoints, verifies and reopens. It passes; no production change.
-
Failure-path correctness map (#843): X08's correctness-target map ties invalid bounds, windows and binds, callback errors, exhaustion, corrupt pages, failed-handle retry, v1–v6 refusal and heavy single-entry WAL replay to 138 named tests at a pinned v3 revision, with their error and data invariants, and limits the C08 repair and C30 rollback claims to their fixtures. Gaps found: heavy WAL replay through
Minigraf::open(#865) and the recursion round bound (#861). X08 stays excluded. No production, test, public API or nightly-route change. -
Semantic permutation map (#842): X07's correctness-target map ties numeric, bind, cross-kind join and forced-planner permutations to 128 named tests at a pinned v3 revision, states what the representative nightly controls do and do not establish, and records the hash-scan and keyword binding/clone confounders. X07 stays excluded. No production, test, public API or nightly-route change.
-
Manual shape sweep (#841): native shape and retention experiments cover sort/window, set and UUID cases, matched recursive controls and sustained pins/FactLogs. The report distinguishes query timings from process RSS and query-meter observations, and retains input rejections, capped attempts and attribution limits. No production, public API or nightly-route change.
-
Regression history and reporting verification (#840): durable API/run evidence establishes numeric comparisons for all 427 native and 26 binding IDs with at least three independent v3 runs each. Separate main/v3 fixes #853/#854 preserve v3 history across measured main advances. Actual scheduled routes, 16 isolated live notification scenarios, four delivered messages and complete synthetic-resource cleanup are verified. This adds no database runtime behavior or Rust tests; further runtime lanes are tracked in #858.
-
Manual migration sweep (#838): frozen fixtures, timings and correctness evidence for native v7-to-v8 migration through 200k facts and Chromium/Firefox/WebKit through 20k facts. Includes nonmigrating native read-only open, v8 reopen and bounded post-migration cursor churn; larger browser cases and sustained pin scaling remain excluded. No production or nightly-route change.
-
Benchmark exclusions audit (#836): pinned enumeration and per-shape dispositions reconcile 156 unselected native IDs, retain 110 manual row-frame cases, and distinguish linear full-prefix UDF accumulation from bounded replay. No new latency measurements or runtime changes.
-
Retire tracker #383 in favor of the v3.0.0 milestone and its release gates. Ordinary/window value-handling consistency is completed by #789.
-
Released binaries use full LTO (#796). The
distprofile cargo-dist builds releases with no longer overridesrelease's full LTO with thin LTO. The CLI is 7–31% smaller on the five release targets (x86_64 Linux 1,501,232 → 1,262,584 bytes, measured before #794) and 4–17% faster on query workloads; clean builds take up to 10 seconds longer.binary-size.ymlnow builds--profile dist, so it measures the shipped x86_64 Linux binary (still under the unchanged 1,263,616-byte cap). Windows packages keep cargo-dist's static C runtime. Seedocs/benchmarks/796-dist-lto.md. -
Sort set element encodings without a stable sort (#794).
set_elementssorts its elements' byte encodings withsort_unstable: equal encodings are identical bytes and deduplicated straight after, so the encoded bytes are unchanged, and the stable sort's scratch allocation and code go. The native release binary is 3,024 bytes smaller (WASI 950, thin-LTO dist 4,112). On the bench machine, ingest, checkpoint, set-constant queries and scans of 100,000 32-element sets in seven input shapes stayed within run-to-run noise. No API, format or error change. -
Unstable sorts for unique query metadata (#795). Prepared-query slot names, an
or's branch variables and a subplan's imported variables sort withsort_unstable: their keys are unique, or, for a join variable named twice, the tied entries are identical, so order and plans are unchanged. Release builds shrink by 1,008 bytes (native CLI), 1,752 bytes (WASI) and 1,886 bytes (browser--lib); prepare and planning time is unchanged within noise. No API, dependency or file-format change. -
Release certified aggregate groups (#781). Ordered aggregation now includes ordinary
sum/avg, fixed-scalarmin/max, and Integer/nilsum-distinct. Effective grouping tuples may include hidden:withkeys, constants and repetitions when they reduce to one ascending Integer/Boolean dimension; existing single numeric/Boolean keys remain supported. Arguments are certified from constants, metadata or raw attribute history using the pinned reader, with pending/staged data and unsupported origins retaining full-input evaluation. Every:withSort is retained. Limits count emitted groups after min/max drops; a retained Sort still drains its input. Completed groups are released, but one growing distinct group remains linear. Small selective grouped queries pay proof startup cost. Common result-buffer memory failures can precede later unread source failures, as on the existing ordered-count path; registered finalizers retain full-drain behavior. No aggregate semantics, syntax, public API, dependency or storage format changes. -
Resolve expression keyword literals once per operator (#774).
=and!=reuse a literal keyword's entity UUID across input rows, including nested expressions, rule bodies and prepared slots. The cache contains only literals from that expression and is discarded with the operator; dynamic operands retain the existing comparison semantics. No API, dependency or file-format change. See the measurements. -
Avoid whole-attribute reads for selective in-memory ancestors (#723). Eligible three-pattern recursive delta runs can start from a small derived relation and probe stored facts by entity, preserving the same semi-naive ranges and answers. The choice is reconsidered each round using bounded pending-index counts. Populated committed generations retain AVET value probes; general value joins retain their existing behavior. No persistent index, dependency, API or format change. The approved v3 binary-size budget increases from 1,230 to 1,234 KiB for this change.
-
Skip sorting proven ordered input (#641, first slice). Plain queries can omit
Sortwhen a committed integer, float, or Boolean range already provides the complete ascending result order. Attribute scans use AVET; entity-and-attribute scans retain their selective EAVT range.:offsetand:limitthen stop reading early, so memory-limit (QRY-012) or I/O errors in unread later input may be avoided. The proof uses the query's pinned reader, including a large write transaction's working trees, and rejects pending or staged records in the source range, mixed kinds, unsupported comparators, tie suffixes, order-changing operators, user callbacks andcollect-set. Unproven queries retain their original plan. -
Release completed count groups (#641, second slice). A plain query with exactly one proved scalar grouping key, no
:withor windows, and only built-incount/count-distinctholds one active group plus bounded batches. Completed groups are released, and a limit can stop before later groups; one large distinct group still grows with its members. Integer, Boolean and committed-float keys qualify, including mixed integer/float grouping inValue::Ordorder. A unique single-kind group key can also remove the final ascending Sort; mixed numeric keys retain numeric Sort. Fallible aggregates, callbacks andcollect-setretain full-input validation and later errors. Small selective grouped queries pay proof startup cost, and a single growing group is slower (seedocs/BENCHMARKS.md). No new aggregate syntax or function is added. -
Release completed window partitions (#641, third slice). A plain query whose built-in windows share one proved nonconstant partition key can hold one partition plus bounded input/output batches, using the existing evaluator for peers, frames and neighbors. The pinned committed source must prove adjacency, with no pending/staged records in its range; scalar value order or dictionary entity/ident order can qualify. Window results preserve their chosen input order; query row order still requires final
:order-by. Unpartitioned or incompatible windows, aggregate queries, callbacks andcollect-setretain full-input evaluation. Transient DICT caches are evicted within long internal pulls and result conversions. One growing partition remains linear, and a surrounding sort/distinct can dominate memory. A limit can avoid later input and its I/O/allocation errors; coalescing can still encounter an error while gathering one output batch. Small selective windows pay proof startup cost (seedocs/BENCHMARKS.md).
-
The recursion round limit defaults to 1,000,000 instead of 1,000 (#861). A stratum only starts another round after deriving a new fact, so its rounds never exceed the facts it derives plus one; with the round default equal to
max_derived_facts' 1,000,000, the derived-facts limit is reached first. A rule query that needed more than 1,000 rounds — for example a transitive closure over a chain of more than about 1,000 edges — used to fail with INT-020 and now returns its rows. SetOpenOptions::max_iterations(1_000)to keep the old limit. -
Valueis#[non_exhaustive]and has aSetvariant (#316). AmatchonValueoutside the crate needs a wildcard arm, so later value kinds are not breaking changes. -
File format v8 (#287, #371). Index keys now include the encoded value and the assert/retract flag. A v7 file (written by v2.x) is re-indexed and upgraded to v8 the first time it is opened; the upgrade is one-way and v2.x cannot open a v8 file (
STG-006). Bindings users get this upgrade silently on open. -
Crash-atomic checkpoints with verified pages (v8 storage format, PR 2 of 5; #374, #388). Two alternating meta pages replace the 84-byte file header and are the only commit point: a checkpoint writes only pages the last commit does not use, syncs, then writes the other meta page and syncs. A crash or torn write at any point leaves the previous checkpoint intact, so opening never rebuilds an index, and the whole-file index checksum is gone. Every other page carries a 24-byte header (type, CRC32, its own page id, the checkpoint generation that wrote it), checked on every read: a torn, corrupted, misplaced or stale page now fails with
STG-029,STG-030orSTG-031instead of being read. If the newest meta page is damaged after its WAL was already deleted, open fails withSTG-033rather than silently going back to the previous checkpoint. New codesSTG-029–STG-035. The WAL header gains a base generation (WAL version 2); a v2.x WAL beside a migrated file is still replayed. Pages freed by a checkpoint are reused by later ones. Files written by a v3.0.0 development build before this change fail withSTG-032. -
Covering indexes with dictionary-coded keys (v8 storage format, PR 3 of 5; #433). Every index entry now holds the whole fact as a byte-comparable key, so a query reads only index pages, never a separate fact page. Entities and idents (attribute names and keyword values) get sequential internal ids from a dictionary stored in the file, and strings longer than 64 bytes are stored once in value pages and deduplicated. Fact pages are gone. A file with 100,000 facts (10 attributes per entity) takes 88 bytes per fact, down from 314. Facts from committed data now come back in internal-id order (entity, then attribute) rather than insertion or UUID order; without
:order-by, result order is unspecified, and code that relied on an order must sort. New codesSTG-036(dictionary entry missing) andSTG-037(one transaction with two timestamps: a checkpoint or v7 upgrade refuses to change a stored timestamp). Files written by a v3.0.0 development build before this change fail withSTG-032. -
Checkpoints cost what changed, not the size of the database (v8 storage format, PR 4 of 5; #434). A checkpoint now inserts copy-on-write: it rewrites only the index leaves that receive new facts and their paths to the root, and reuses pages freed by earlier checkpoints through a free list it reads and extends only as far as it needs. A checkpoint after one new fact takes 3.1 ms on a 10,000-fact file and 3.3 ms on a 100,000-fact file (it took 15.5 ms and 43 ms before, and grew with the file). One new fact at 100,000 facts writes 16 pages. The
checkpoint()andwal_checkpoint_thresholddocs now describe this cost. -
A damaged page is reported, not worked around (v8 storage format, PR 5 of 5). A query whose indexed lookup reaches a damaged page now fails with that page's
STG-029/STG-030/STG-031. Before, the query fell back to a full scan, which could hide the damage. Tests damage an index leaf, an internal node, a dictionary leaf, a value page and a free-list page in turn. Every read or checkpoint that reaches the page fails, and the file still opens at its last checkpoint. Measured at 1.15M facts in #433's shape: 142 bytes per fact, history included. A checkpoint after one new fact takes 6.1 ms at 1M facts. Concurrent point queries are 19–31 % faster than before this change: reads reuse th 10BC8 e entity and attribute the caller passed, and share cached ident and timestamp lookups. -
Size limits for file-backed databases: a string value may be up to 4,068 bytes, whatever the rest of the fact, and an attribute name or keyword value up to 1,024 bytes. Both are checked when the transaction is written and fail with
WAL-003, whose message now names the value. This replaces the 4,080-byte limit on a whole serialised fact (4,052 in earlier v3.0.0 development builds). In-memory databases have no limit. -
Formats v1–v6 are no longer readable. Opening one fails with the new
STG-028; open it once with Minigraf v2.x to upgrade it to v7 first.STG-003andSTG-004are deprecated (no longer emitted, still registered, never reused).STG-005andSTG-006message text changed. -
Empty or inverted valid-time windows are rejected (#436). A
transactwhose effective window for any fact ends at or before it starts fails with the newAPI-019, writes nothing and takes no transaction number. The effective window is the one after defaults, so(transact {:valid-to "2020-01-01"} ...)is rejected too: its:valid-fromis the transaction time. This applies toexecute, eachWriteTransaction::executestatement and, with the commit time,WriteTransaction::commit, and the browser. Before, such a fact was stored, never matched:valid-at, and still showed up under:any-valid-time. Files that already hold such facts open and read as before. -
In a
WriteTransaction, the last statement that writes a fact decides it (#477). When severalexecutecalls in oneWriteTransactionwrite the same(entity, attribute, value), in any mix oftransactandretract, the commit keeps only the records from the last of them. Before, reads inside the transaction already followed statement order, but the commit stamped every record with one transaction number and a retraction always won: a fact retracted and then asserted again read as live beforecommit()and was gone after it. Two windows of one fact in separate statements, which wereAPI-011, now commit the later window.API-011is limited to onetransact;tx.execute()raises it and stages nothing, and the transaction stays usable.LogWriterkeeps the same rules (see the log writer entry). -
A
:findvariable that nothing binds is an error (#406). A query whose:findnames a variable that no:whereclause binds now fails at parse time with the newPRS-080, naming the variable, fromexecute(),query()andprepare(). Before, it returned no results, so a typo in:findlooked the same as "no matching data". The same check covers a window function's argument,:partition-byand:order-byvariables. A variable counts as bound when it appears in a pattern, a rule call, an expression binding, anor-joinjoin vector or every branch of anor; one that appears only insidenot/not-join, or a?_wildcard, does not. -
An unknown aggregate or window function is an error whatever the data (#414). Aggregate and window function names in
:findare now resolved before the query runs: an unknown aggregate fails with the newQRY-010and an unknown window function with the newQRY-011, even on an empty database or when no row matches. Before, such a query returned no results until a row matched and then failed withINT-029/INT-030, which stay registered for internal inconsistencies only. -
One current valid-time window per fact (#435). At any transaction time, each
(entity, attribute, value)now has exactly one current valid-time window: the one from its latesttransact. A later assertion of the same fact replaces the earlier window, which stays visible through:as-ofof the earlier transaction. Before, every window ever asserted stayed live until aretract, so closing an open window with(transact {:valid-from vf :valid-to D} ...)left the open one live and:valid-atafterDstill found the fact. Closing, extending or reopening a window is now a plaintransactwith the new bounds;retractstill withdraws the fact. This changes query results for existing data: a fact asserted in several transactions with different windows is now valid only in its latest window at the current transaction time. A fact that was true over two separate periods can no longer have both windows live at once; model each period as its own entity, or read earlier periods with:as-of. Asserting one fact with two different windows in onetransactis rejected with the newAPI-011, and nothing is written. Data written before this check with two windows in one transaction keeps both. -
The
uuiddependency no longer enablesv4(#439). The library only makes v5 UUIDs, so thev4feature and, on browser wasm,jsare gone from itsuuiddependency, and with themgetrandom. A crate that calledUuid::new_v4only because Cargo feature unification turnedv4on through minigraf must now enableuuid/v4itself. The release binary shrinks by 48 bytes. -
Join answers no longer depend on the query plan (#545). Two cases changed. A join on a float value now pairs
0.0with-0.0, as a shared variable compared after the join key already did; before,Value's hash told them apart, so the join key never paired them. NaN still equals nothing. (Float constants in a pattern matched withinf64::EPSILONuntil #706; see Fixed.) And a variable bound to a keyword by one pattern and to the ref of the entity the keyword names by another, which already joined, now returns the kind from the first of those patterns in the:wheretext. Before, it returned the kind from the pattern the planner ran first, so the answer could change with the selectivity of the other patterns, and differed under thewasmfeature, which does not reorder. For example, with[:alice :friend :bob]stored as a keyword,[:find ?b :where [?a :friend ?b] [?b :name "Bob"]]returned the ref of:bob, because the planner runs the pattern with two constants first, and now returns the keyword:bob. Expression bindings follow the next entry (#596). The rule covers the top-level:whereof queries with and without rules; insideor,notand rule bodies the kinds are unchanged. -
An expression binding a variable that another clause binds keeps only the rows where the two agree (#596).
[(f …) ?x]used to overwrite?xwherever the planner ran it, so when a pattern also bound?xthe answer depended on whether that pattern ran first, and differed under thewasmfeature, which does not reorder. Now the expression unifies: the row is kept when its result equals?x's value, with the join equality of #545 (floats by IEEE==, a keyword equal to a ref to the entity it names, an integer never equal to a float, NaN equal to nothing). For example, with[:a :age 30] [:b :age 40] [:p1 :score 41],[:find ?a ?x :where [?e :age ?a] [:p1 :score ?x] [(+ ?a 1) ?x]]returned[30 31] [40 41]natively and now returns[40 41], as it did underwasm.[(+ ?v 1) ?v]never matches, and two expressions binding one variable must agree. When the two values differ only as keyword and ref, the variable returns the value of the first clause in the:wheretext that binds it, expressions included. This applies toorbranches,notbodies and rule bodies too, on the executor and on the streaming engine. -
A value a
not/not-joinbody takes from the outer row compares as a join compares it (#546). The executor had two paths for a negation body: the exclusion-set path compared the outer row's values as joins do (floats by IEEE==, a keyword equal to a ref to the entity it names), while the per-row path, taken when the body held an expression or a pseudo-attribute, in a query that invokes a rule, in anorbranch and in a rule body, substituted each outer value into the body as a literal, so a float matched withinf64::EPSILON. The answer therefore changed with the shape of the body: with[:e1 :x 1e-20] [:g :y 2e-20],[:find ?e :where [?e :x ?f] (not-join [?f] [?g :y ?f])]returned:e1and[:find ?e :where [?e :x ?f] (not-join [?f] [?g :y ?f] [(> ?f 0.0)])]returned nothing. Every path now matches the body from the outer values, compared by join equality, so both return:e1;0.0and-0.0are equal and NaN equals nothing. A literal constant in the body still matches floats withinf64::EPSILON. -
An
or/or-joingives the same answer on every executor path (#546). The executor evaluatedorbranches on two paths, and their answers differed. The joined path, taken unless a branch held anot, a pseudo-attribute reading an outer pattern's metadata or an expression reading an outer variable, ran each branch alone and joined its rows to the outer rows. The per-row path started each branch from the outer row. Three cases changed, all to the per-row answer. (1) The joined path compared values exactly, so a keyword never met a ref to the entity it names: with[:alice :friend :bob] [:bob :name "B"],[:find ?e ?n :where [?e :friend ?f] (or [?f :name ?n] [?f :alias ?n])]returned nothing and now returns:alice "B"; floats compare by IEEE==. (2) It joined every branch's rows on the outer variables any branch names, so(or [?e :a ?x] [?f :b ?x])matched nothing; each branch now meets the outer row on the variables it names. (3) It dropped the rows of anor-joinbranch that does not bind every join variable; such a branch constrains nothing about that variable, so(or-join [?e] [?x :a 1] [?e :b 2])keeps every outer row when some entity has:a 1. Rows anorreturns are still distinct. Also, with no:valid-at,:db/valid-atin a branch read the wall clock instead of the query's "now"; it now reads the query's. -
A per-fact pseudo-attribute reads the nearest pattern before it with the same entity (#594).
:db/valid-from,:db/valid-to,:db/tx-countand:db/tx-idread the metadata of one fact: the one matched by their source, chosen when the query, rule or prepared query is parsed, as the nearest pattern before them in the:wheretext with the same entity term and a stored attribute (a keyword, or a variable as in[?e ?a _]). The search starts in the pseudo-attribute's own clause list and moves outwards, before each enclosingnot/or; it crosses anot-join/or-joinonly for an entity variable in its join variables, and stops at a rule body, which never reads its caller's patterns. Terms are the same when they are one variable, one$slot, or constants naming one entity (:aliceand its UUID). Before, they read the last pattern the planner ran that matched the entity, so the answer depended on the plan and differed under thewasmfeature: in[?e :person/name ?n] [?e :person/age ?a] [?e :db/tx-count ?t],?tcould be the tx-count of either fact, and is now the:person/agefact's. Three further changes follow: (1) with no source, the query fails at parse time with the newPRS-081instead of matching every visible fact of the entity ([?e :db/tx-count ?t]before[?e :person/name ?n], a_entity, a pseudo-attribute in a rule body whose only candidate is in the caller); to read every fact of an entity, write[?e ?a _]before it; (2) insidenot,not-join,orandor-join, a pseudo-attribute reads the outer fact, so[?e :person/name ?n] (not [?e :db/tx-count 1])excludes the entities whose name fact is from tx 1, where before the body matched any fact of the entity; (3) rows that differ only in a source fact's metadata are no longer merged byor.:db/valid-atis unchanged. The pseudo-attribute pattern now binds or checks a value; it never scans facts, so the planner places it after its source rather than by selectivity. -
A pseudo-attribute pattern never gives a variable its value (#606). A variable bound by several patterns returns the value of the first real pattern in the
:wheretext that binds it (#545); a:db/valid-atpattern that the planner ran first used to bind its entity variable itself, as a ref, so[:find ?b ?t :where [?b :db/valid-at ?t] [?a :friend ?b] [?b :name "Bob"]]returned the ref of:bobwith source order and the keyword:bobstored by:friendnatively. It returns the keyword now, whatever runs first. Only the keyword-or-ref kind changes; rows and counts are the same. -
Every build reorders query clauses; the
wasmfeature does nothing (#594, #596). ThewasmCargo feature kept patterns,notclauses andorbranches in source order and turned off theorshort-circuits (#250), for small browser datasets and a predictable order. With every answer independent of the plan (#594, #596, #606) it only made queries slower, and no first-party build enabled it (the browser package usesbrowser). The feature is kept, so builds that name it still resolve, but is deprecated and has no effect. -
Aggregate and window answers no longer depend on the order of their input (#547). Before, aggregates and window functions read their rows in the order the plan produced them, and several answers followed that order. Six cases changed. (1)
sumover floats is exact: the values are added without rounding and the total is rounded once, so1e16 + 1.0 + -1e16 + 1.0 + 0.5gives2.5in every order (before,1.5or2.5). Windowsumandavglikewise. (2)sumover integers saturates once, at the end:i64::MAX + 1 + -1givesi64::MAX(before,i64::MAX - 1ori64::MAXby order). (3) Windowsumover integers adds them exactly; before they were added as floats, so a running total above 2^53 lost its low digits. (4)min/maxof floats follow one order:-0.0before0.0, NaN after every other number (before, the first of two such values seen won). Windowmin/maxuse the same order over any values, so the minimum of keywords is the first in text order. (5) Window functions sort each partition by:order-byunder one total order of values (numbers by numeric value, an integer before an equal float, then strings, booleans, refs, keywords,nil), so keywords, refs and booleans now sort (before, any two of them compared equal and kept their input order). Rows with equal:order-byvalues are peers:rankgives them the same rank, andsum,count,min,max,avgand registered window functions give every peer the value at the last peer, as SQL does;row-numbernumbers peers in the order of the other variables:findnames. For example, with?sof 1.5, 5, 10, 10,(sum ?s :over (:order-by ?s))gave 1.5, 6.5, 16.5, 26.5, the 16.5 going to either tied row, and now gives 1.5, 6.5, 26.5, 26.5; ranking:x :x :y :yby:order-bygave ranks depending on the order the scan met them (1 2 3 4 when interleaved) and now gives 1 1 3 3. (6) In a query with aggregates, a window's argument,:partition-byand:order-bymust be plain:findvariables, or the query fails at parse time with the newPRS-082; before, any other variable readnilon every group row. The error a failing aggregate query reports is the first error, in:findorder, of the first failing group in output order. A registered aggregate still receives its values in input order, which can depend on the plan; one whose result depends on that order may answer equivalent queries differently. (7) A group whose grouping variable holds floats that are equal as groups compare them (-0.0and0.0, or NaNs) returns the least of them in that order, so-0.0when the group holds it; before, it returned the first one the plan met. -
Parse errors that were
INT-019now carry their ownPRScodes (#643). The parser's long-tail syntax and structure errors (a bind slot with no name, a window without:order-by, a non-vector query, malformed:with,:find, valid-time map, retract fact,not/not-join/orbody, pattern and rule) used one internal code,INT-019, with adatalog parser:prefix. Each now has its own Parser-category code,PRS-083toPRS-107, documented indocs/ERROR_REFERENCE.md; the message is the old text without the prefix. A retract fact without exactly three elements wasINT-000and is nowPRS-090. Code that matches onINT-019(orINT-000for retract arity), or on theInternalcategory, for these errors must match the new codes.INT-019stays registered for parser invariants no input is known to reach. -
The parser rejects what the grammar rejects (#651). The grammar verifier (#643) found inputs the parser accepted that
tests/grammar/grammar.pestanddocs/GRAMMAR.mddo not describe, several with wrong answers. Each is now an error:- A value after a query option and its argument (
:as-of N,:valid-at "…",:any-valid-time,:max-derived-facts N,:max-results N) joined the section before the option:[:find ?a :any-valid-time ?b :where …]returned?bas a second column. It now fails withPRS-061. - A
:wherelist headed by an operator or a variable,(< ?a ?b)or(?r ?a), was a call of a rule of that name and returned no rows. It now fails withPRS-097; a comparison is an expression clause,[(< ?a ?b)]. - A rule named by a variable or an operator,
(rule [(?r ?a) …]), fails withPRS-105, and a rule head argument that is not a variable,(rule [(r ?a :c) …]), with the newPRS-114(it bound the caller's argument to the constant; bind the variable in the body instead). - An aggregate, window function or expression named by a variable,
(?f ?x), failed when the query ran (QRY-010,QRY-007); it now fails to parse withPRS-025,PRS-029orPRS-063. (query [])fails with the newPRS-111;:find,:whereor:withwith nothing after it (an empty:findreturned one empty row) with the newPRS-112; a keyword the query vector does not know, such as:foo, with the newPRS-113(it was ignored when another keyword followed, andPRS-061otherwise).- A string with no closing quote fails with the new
PRS-109(it ran to the end of the input), and a:with no name after it, such as:+, with the newPRS-110(it was the keyword:).
A lone
-stays the subtraction operator; the grammar now lists it with the other operators. The verifier's #651 entries are removed. - A value after a query option and its argument (
-
One rule limit,
max_derived_facts, on the total facts a rule query derives (#553).OpenOptions::max_derived_factsand:max-derived-factsnow limit every fact the rules of one query derive, across iterations and strata (the database's own facts do not count); they used to limit the new facts of one iteration.OpenOptions::max_resultsand:max-results, which already limited the total (#528), are deprecated aliases of it (#[deprecated]on the Rust field and builder method); the lower of the two applies, and a per-query value still replaces the database's.OpenOptions::max_resultsnow defaults to no limit of its own, so it can lower the limit but not raise it: a caller who raisedmax_resultsabove 1,000,000 must raisemax_derived_factsinstead. Neither ever limited the rows a query returns, whatever the name said;:limit(#306) will. A caller who loweredmax_derived_factsto cap each iteration now caps the total, so a long recursion whose iterations each stayed under the limit can now fail withINT-020. TheINT-020text now readsevaluator: iteration or derived-fact limit exceeded: Max derived facts (N) exceeded. …. The 1,000-iteration limit and the 1,000,000 default are unchanged. -
A rule head whose entity is not an entity fails with
QRY-013(#548). A rule head's first argument is the entity of the fact it derives and must be a ref or a keyword; a string, number, boolean ornilthere now fails the query with the new codeQRY-013(rule head entity is not an entity: rule 'named' gives a String where a ref or a keyword is needed). A positive rule used to fail withINT-022, and a rule withnot,not-join,ororor-joinsilently dropped the row. A head variable the body does not bind now fails withINT-022on thenot/orpath too, which also dropped the row. -
A fact a rule derives has no metadata (#548).
:db/valid-from,:db/valid-to,:db/tx-countand:db/tx-idread from a derived fact ((reach ?x ?y) [?x :reach ?y] [?x :db/tx-count ?c]) now bindnil, and a constant ([?x :db/tx-count 1]) never matches one; a stored fact with the same attribute keeps its own. They used to bind the rule evaluator's working values: the time the query ran, forever, and a counter of its own.:db/valid-atstill binds the query's valid time. -
A
notor nestedorinside anorbranch sees a variable an expression of the branch binds (#551). The executor applied a branch's patterns, then its nestedors, then itsnots, then its expressions, so anotor nestedorreading a variable that only an expression of the branch binds saw it unbound. A branch now runs in the order a:wheredoes (#625), and thenotsees the binding. For example, over[:x :n 1] [:y :n 2] [:z :n 5],[:find ?e :where [?e :n ?v] (or-join [?e ?v] (and [(+ ?v 1) ?w] (not [?f :n ?w])) [?e :n 5])]returned:z(thenotmatched any fact, so the first branch kept no row) and now returns:yand:z. Only such branches change. -
An unknown predicate is an error when the query opens, wherever it is (#551). Every predicate a query names, in an
orbranch (of the query or of a rule it evaluates) or anot/not-joinbody, is now looked up when the query opens, as top-level predicates and:findfunctions already were (#414): an unknown one fails withQRY-007whatever the data. Before, the executor looked it up only when a row reached it. In anorbranch, a query whose rows never reached the predicate returned an answer, and now fails. In anot/not-joinbody the failure was swallowed: the body had no row, so thenotkept every outer row and the query returned a wrong answer instead of an error. -
UniFFI bindings raise API errors as
MiniGrafError.Api(#181). Python, Java, Android and Swift reported everyAPI-code (a bad argument, a closed object, a read-only write, and the new bind-value codes) asMiniGrafError.Other; they now raise the newMiniGrafError.Api, with the same message. Code that matchedOtherfor these must matchApi, and an exhaustivewhen(Kotlin) orswitch(Swift) overMiniGrafErrorneeds the new case. -
Bind-value mistakes are
APIerrors (#181). A missing bind value, a bind slot in an attribute position and a bind value of the wrong kind for its slot wereINT-033,INT-034andINT-035, codes for internal faults, although each is the caller's mistake. They are nowAPI-022,API-023andAPI-024, with the same messages, fromprepare,PreparedQuery::executeandPreparedQuery::query. A caller that matched theINTcodes must match the new ones.INT-033–INT-035are deprecated (no longer emitted, still registered, never reused).
- Bounded sliding row frames (#183).
:rows N precedingadds a physical-row frame to window aggregates. Built-in aggregates update the frame in linear time after sorting; registered aggregates replay bounded frames unless the frame covers the whole prefix. The default peer-based frame is unchanged. New parser codesPRS-124andPRS-125report malformed frames and frames on position functions. lagandleadwindow functions (#182).(lag ?value :over (:partition-by ?group :order-by ?key))reads the previous row's value;leadreads the next row's. Both use the established total partition order, including named-variable tie breaks, and returnnilat partition edges.:descreverses the primary key, so the previous row has a higher or equal key. They run over grouped rows after aggregation and before:distinct, sorting and pagination. An optional offset or explicit default is not part of this release. The plain(lag ?v)and(lead ?v)forms remain available to registered user aggregates.PRS-030is deprecated and no longer emitted.- Optional distinct query results (#493). A bare
:distinctflag removes repeated final:findtuples after aggregates and windows and before:order-by,:offsetand:limit. Queries still return bags by default;:withkeeps its aggregate grouping behavior. Equality followscount-distinct(including one NaN and one floating zero), with canonical-0.0/NaN output, also inside sets. The distinct keys count towardquery_memory_limit; ordered top-k queries still retain a key for every unique tuple they encounter. - Fallible and re-entrant user functions (#180, PR 1).
Minigraf::try_register_predicateandMinigraf::try_register_aggregatetake closures that returnResult<_, UdfError>(UdfErroris exported at the crate root). A call that returnsErrfails the query with the newQRY-014,user function `<name>` failed: <message>, at the first failing call: it is never read asfalseornil, and no later call of any user function is made for that query.executeandPreparedQuery::executereturn it; a cursor treats it as any other read error, returning the rows of the batches finished before the failure, then the error, thenNone.register_predicateandregister_aggregateare unchanged. A query now reads its functions from a snapshot of the registry taken when it opens, and holds no database lock while a user function runs: a function registered later is not seen by a query or cursor that is already open, and a user function may call back into its own database (query, prepare, read the fact log, write, define rules, register functions, checkpoint, verify, rebuild indexes). Its writes are not seen by the query that called it. Inside aWriteTransactionthose calls returnINT-001on that thread, except registration, which runs. Before, a user function that registered a function deadlocked. This is the core of UDF registration in the language bindings, which follows binding by binding. - User functions in
BrowserDb(#180, PR 7).db.registerPredicate(name, fn)anddb.registerAggregate(name, { init, step, finish }), both synchronous. A function receives each value as a{ type, value }object, as@minigraf/node'sFactValue(an integer'svalueis abigint, a ref's its UUID string, a set's an array of these); a predicate returns a boolean;init()returns an accumulator,step(acc, value)the next one andfinish(acc, count)the result, as a plain value or a typed form, as a prepared query's binding takes it. A function that throws fails the query withQRY-014and the error'smessage(elseString(e)), and so does a predicate returning a non-boolean or afinishresult that is not a value. A UDF may callquery,prepareand theregister*methods synchronously;execute,checkpointandimportGraphstarted from one run after the outer query returns. The registry holds no JavaScript value, only ids into a table local to the registering thread, so the crate stays free ofunsafe. :limit Nbounds the rows a query returns (#306).[:find … :where … :limit 10]returns at most 10 rows of the answer, and the engine stops reading once it has them: over 10M facts:limit 10reads one internal batch. Without:order-bywhich rows is unspecified, and may differ between plans; they are always rows of the unlimited answer.:limit 0returns no rows. It applies after aggregates and windows, to rule queries and to cursors, and a prepared query takes:limit $n, bound withBindValue::Val(Value::Integer(n)). A value that is not an integer>= 0or a slot fails with the newPRS-115; a repeated:limitwithINT-012and a missing value withINT-013, as for the other query options.:max-resultsand:max-derived-factsstill limit only the facts rules derive.:order-byand:offset(#306).:order-by [(count ?e) :desc ?name]orders the rows a query returns by:findelements (plain variables, or aggregates and windows written as in:find), each:asc(the default) or:desc; ties are broken by the other:findcolumns ascending, so every position of an ordered answer is decided and every plan returns the same list. Values compare as windows andmin/maxcompare them: numbers by value (an integer before an equal float,-0.0before0.0, NaN after every number), then strings, booleans, refs, keywords,niland sets.:offset Nskips the firstNrows of the ordered answer, before:limit, so:offset 20 :limit 10is the third page of ten. A newSortoperator reads its whole input; under a:limitit keeps only the bestoffset + limitrows, so:order-by [?v :desc] :limit 10over 10M facts holds ten rows, not ten million. It applies after aggregates and windows, to rule queries, cursors and prepared queries (:offset $o, bound like:limit), and counts towardquery_memory_limit. New codes:PRS-116(:offsetwithout:order-by: without an order, pages could overlap or miss rows),PRS-117(an entry that is not a:findelement) andPRS-118(:order-byis not a non-empty vector, names an element twice, or has a misplaced or unknown keyword).PRS-115now also covers:offset. Inside a window's:over,:order-byis unchanged.- Set values:
#{...}(#316, PR 1 of 3). A fact's value can be a finite set of scalars,[:alice :user/scopes #{:scope/read :scope/write}], written in query text, returned asValue::Set(ValueSet), transacted, retracted and queried by exact value ([?e :user/scopes #{:scope/write :scope/read}]), joined on and grouped by. Duplicates are removed and element order does not matter. Two elements are the same whenValue's order says so:0.0and-0.0are one element (-0.0is kept), every NaN is one element, a keyword never equals a ref and1never equals1.0. A set cannot hold a set (ValueSet::newfails with the newAPI-021; in query text,PRS-119); its elements are literals only (PRS-120), and an unclosed#{isPRS-121. In format v8 a set has value tag0x08, after refs; a set whose encoded elements take at most 64 bytes is stored in its index keys, a longer one once in a value page, as a long string is. On a file database a stored set may take up to 4,068 bytes, counting each keyword element as 6 bytes and each ref as 10 (WAL-003). The REPL prints sets as#{...}andBrowserDbreturns them as JSON arrays, inValue's order. - Set-membership predicates (#316, PR 2 of 3).
[(in? ?role #{:role/admin :role/owner})]keeps rows whose value is in a set, andnot-in?,any-in?(the two sets share an element),all-in?(every element of the first is in the second) andnone-in?(they share none) complete the five. Each works as a filter or binds a Boolean,[(any-in? ?scopes #{:scope/write}) ?can-write], and takes literal sets, set-valued attributes and bind slots (BindValue::Val(Value::Set(..))). Membership is set equality, as for set values:0.0is in#{-0.0}, NaN is in a set holding NaN, a keyword is not a ref.in?of an empty set is false andnot-in?true;all-in?andnone-in?of an empty first set are true andany-in?false. A set where a scalar is needed, or a scalar where a set is needed, is a type mismatch: the filter drops the row and the binding binds nothing, sonot-in?with a non-set is never true.contains?stays string-only. The five names cannot be registered as functions. collect-set(#316, PR 3 of 3).[(collect-set ?e :user/scope) ?scopes]binds, per row, the set of the values of[?e :user/scope ?v]under the query's:as-ofand:valid-at, with retractions applied as for that pattern, and#{}when there are none (the row is kept). Combine it with the set predicates:[(collect-set ?e :user/scope) ?s] [(in? :scope/admin ?s)].?emust be bound by an earlier clause (INT-018); a keyword names its entity, and a value that is no entity gives#{}. It works in:where, rule bodies,not-joinbodies andorbranches; when its output variable is already bound, it keeps the rows where the two sets are equal. It reads stored facts only, never a rule's derived facts. New codes:PRS-122(no output variable),PRS-123(an operand that is not a?variablethen an attribute keyword, a bind slot included) andQRY-015(a value found is itself a set: bind a set-valued attribute with a pattern). The set it builds counts towardquery_memory_limit(QRY-012).collect-setis read only as a whole clause (PRS-067inside another expression) and cannot be registered as a function.BrowserDbbinds sets (#316). A prepared query's binding can be a set:{ type: 'set', value: [1, 2] }or a JSSet. Elements are plain values or typed value forms ({ type: 'keyword', value: ':k' }); an element that is an entity, tx count, timestamp or any-valid-time form isAPI-017, a set inside a setAPI-021. Any other element, and a plain array as a binding, is aTypeError, as for any value that is no bind value.- Set values in every binding (#316). Python, Java, Android and Swift return a set value as
MiniGrafValue.Set(its elements in canonical order) in fact records and take it in bind values and the log writer; Node.js and C as{"type": "set", "value": [...]}. Each binding's native set binds a set value: a Pythonset/frozenset, a Kotlin/JavaSet, a SwiftSetofMinigrafSetElements, a JSSetin Node.js and the browser; a UUID element is a ref. A set inside a set isAPI-021.execute()returns a set as a JSON array, in every binding. - Every query runs on a streaming engine (#473: #544, #545, #546, #547, #548, #549, #551). The executor, which turned every live fact of each pattern's attribute into a
Factbefore matching, held each row's bindings in a map and copied the database into aFactStoragefor every rule query, no longer runs queries: its query path,PatternMatcherand the old rule evaluators are gone (#551), and the release binary is 1,153 KiB instead of 1,286 KiB (CI's size check). A query is now a tree of operators that pass batches of up to 1,024 rows of ids, read from the index keys, and turn ids into values only for the rows they return (#530). A pattern reads the narrowest key range its constants allow ([?e :a C]reads the AVET range ofC, #518); a later pattern is anIndexNestedLoopJoinwhen its entity is bound and aHashJoinotherwise, built on the side with the smaller estimate (with no shared variable, the smaller product, #575); per-fact pseudo-attributes read their source's metadata column (#594).not/not-joinis anAntiJoinandor/or-joinaUnionandDistinct: a body or branch with the same rows for every outer row runs once into a hash table, any other runs per outer row from a one-row seed. Aggregates keep one entry per group (one set of accumulators when ungrouped), windows hold their rows, and rule queries derive each stratum's relations semi-naively, with the magic-set rewrite, then read them as patterns.:as-ofwith a time bounds the transaction counter when transaction times rise with it, and otherwise keeps the versions of transactions at or before the time. Cursors (Minigraf::query,PreparedQuery::query,BrowserDb.query) pull one batch at a time, and closing or dropping one stops the work; a cursor still answers from the database as it was when it opened. Every operator that keeps state counts it againstOpenOptions::query_memory_limit(#549, below). Answers are the executor's, as a bag, except for the changes under Breaking changes: until #551 every query in the test suite was also run by the executor and the answers had to agree, and the answers of the suites that relied on that are recorded intests/answers/. Measured with the #394 envelope at 1M facts, executor → engine, p50 and peak RSS (docs/BENCHMARKS.mdhas every shape at 100K, 1M and 10M):[?e :name "…"]288 ms → 22 µs; an attribute scan of 166,666 rows drained withnext_batch(1000)58 → 7 MiB (458 → 7 MiB at 10M);[:p :follows ?f] [?f :name ?n]796 ms → 50 µs; an entity-boundnot760 → 27 ms;[:find (count ?v) :where [?e ?a ?v]]3.01 s → 457 ms and 728 → 7 MiB (6.6 GiB → 7 MiB at 10M); anor-joinof two hashed branches 1.44 s → 80 ms;(boss :p<leaf> ?b)43.9 s → 2.14 ms and 2.6 GiB → 4 MiB; the whole transitive closure of:manager51.0 s → 3.21 s and 4.7 GiB → 318 MiB;:as-ofwith a time, a point query, 1.65 s → 0.8 ms and 435 → 4 MiB (17.2 s and 3.6 GiB → 0.9 ms and 5 MiB at 10M). - Transactions of any size on a file database, in bounded memory (#473, part 8: #550).
transactandretractare read one fact at a time: the whole command is never tokenized, parsed or materialised at once, and a statement reports the same error code as before whatever its size. A transaction becomes large when its facts would pass the newOpenOptions::transaction_chunk_facts(default 65,536, kept between 1 and 1,000,000):Minigraf::executeof a larger statement, or theWriteTransactionstatement that takes its staged facts past it. A large transaction holds at most one chunk of facts in memory. It writes them a chunk at a time into copy-on-write trees that become the next generation, and commits as a checkpoint: no WAL entry, and the uncheckpointed facts of earlier transactions go into the same checkpoint. What a large transaction stores is what a small one stores, with two differences you can see: itstx_idis the time it became large, not its commit time (a default:valid-fromtakes that time too), and errors from different chunks of one statement come in chunk order. In a largeWriteTransactiona rejected statement still leaves the transaction usable and the earlier statements' facts as they were; a fact too large to store is rejected at its statement (WAL-003) rather than atcommit; a handle that has failed is refused when the transaction becomes large (STG-046) rather than atcommit; and inside the transaction:as-ofits own transaction number sees every finished statement, since they share one number (staged statements of a small transaction each have their own untilcommit). A crash at any point before a large transaction's commit leaves the database as it was; the WAL file exists, header only, from before its first page write, so a new file reopens too. The first checkpoint after a WAL is found at open, or after a large transaction is rolled back, now writes a meta page before it deletes the WAL. Measured at 1M facts against v3 before this change (oneexecute, or aWriteTransactionof 10 statements): peak memory above the input text goes from 1.3–2.4 GiB to at most 55 MiB at the default (under 10 MiB at 16,384); with sequential entities it is faster (3.9 s from 9.1 s for short values), with random entities about as fast for short values (9.5 s from 11.4 s in oneexecute) and slower for long ones over 10 statements (34 s from 22 s; 16,384 takes up to twice as long as the default with random entities), and a transaction of several statements over random entities can leave the file up to 1.8× larger, since each statement keeps the pages its base references until it ends; later checkpoints reuse that space. - Queries inside a
WriteTransactionread through a snapshot (#550). They no longer copy every committed and pending fact into a new list: they read the committed indexes and the pending facts, with the transaction's staged facts read with the pending ones (or, in a large transaction, its working trees), and run on the streaming engine where it plans them, counted byquery_memory_limit. - Query memory accounting and
OpenOptions::query_memory_limit(#549). Every engine operator that keeps state counts an estimate of the bytes it holds, per entry, the same on every platform: a hash join's build side, a negation's or anor's table, a distinct set, an aggregate's groups (count-distinctandsum-distinctmembers included), a window's rows, the facts a rule query derives and the stored facts it compares them with, and the query's entity and ident ids. Tables grow throughtry_reserve. The newOpenOptions::query_memory_limit(defaultusize::MAX, off) turns a query that would hold more into the newQRY-012, which frees what the query held and leaves the database and other queries as they were; through a cursor, the rows already returned stay returned. An allocation the allocator refuses isQRY-012too, in place of the abort. Rows passing between operators and the answerexecutecollects are not counted, nor are a write transaction's staged facts (at most one chunk, #550). - Binding support for cursors, open options, the fact log and the log writer (#462, #465, #467). Two new codes let language bindings report what Rust's types rule out:
API-017(an argument the binding cannot convert, such as an entity id that is not a UUID string), fromMinigrafError::invalid_argument, andAPI-018(a log writer used afterfinishorclose), fromMinigrafError::closed.BrowserDb.query(datalog)returns aBrowserCursorwithvars(),nextBatch(maxRows)(rows as JSON, as inexecute(), orundefinedat the end) andclose(). The shared binding shape is indocs/superpowers/specs/2026-10-07-binding-etl-surface-design.md. The wasm32 test build compiles again (a duplicatepage_cache_capacityandtempfiletests on wasm32). - Fact log:
Minigraf::fact_log()(#430). Streams every fact version in the database asFactRecords (entity, attribute, value,tx_count,tx_id, valid-time bounds, and whether it is an assertion or a retraction), without Datalog.FactFilterselects attributes (exact or by prefix), entities and atx_countrange, checked on index keys so excluded records are never decoded.FactOrder::Tx(the default) returns records in ascendingtx_count; format v8 has no index in transaction order, so it holds at mostFactFilter::windowrecords (default 262,144) and scans the selected keys once per window.FactOrder::Storagereads in one pass. The log reads the database as it was when it opened. Writes, checkpoints andrebuild_indexes()go on while it is open (#543): the log pins the committed generation it opened on, and no checkpoint reuses a page that generation references, so the file grows by the pages checkpoints replace until the log is closed, dropped or read to the end; later checkpoints reuse them.API-013(checkpoint while a fact log is open), added with the fact log in earlier v3.0.0 development builds, is deprecated: no longer emitted, still registered, never reused. LikeCursor, aFactLogis owned andSend. Rust API only for now; the bindings follow. - Raw log writer:
LogWriter(#431). Builds a new database from explicitFactRecords, the load step of an offline migration afterfact_log()on a read-only source. Each record keeps itstx_count,tx_id, valid-time window and assertion flag. Records come in transaction order, gaps allowed: leaving a transaction out leaves a hole, and:as-ofon it shows the state before.advance_tx_countkeeps the source's counter after trailing purged transactions. The checks are structural only: order (newAPI-015), onetx_idper transaction (newAPI-016) and size (WAL-003); a rejected record changes nothing. Every file it builds keeps the rules of a normal write (#436, #477): a valid-time window that ends at or before it starts isAPI-019, a second window of one fact in one transactionAPI-011, and an assertion and a retraction of one fact in one transaction the newAPI-020. An unfiltered copy of a source that keeps these rules is identical to it. A source written before them (v2.x, or earlier v3 builds) can break them, and copying it fails at the first such record, so a middleware step must repair those records first;docs/ERROR_REFERENCE.mddescribes each repair. There is no WAL: records are committed in batches with the normal copy-on-write checkpoint, never splitting a transaction. The file is built at<path>.partialand renamed into place byfinish, so a crash or an abandoned build never leaves part of a log atpath; an existing target or WAL is the newSTG-043. The page-range copy the issue proposed is not provided, since v8 pages share one dictionary and are not self-contained. The bindings follow. - Read-only open:
OpenOptions::read_only(#429). Opens a database so that nothing done through the handle writes to the.graphfile or its WAL. The file is locked in shared mode: any number of read-only handles, in this process or others, can hold it at once, and they exclude a read-write open (and it excludes them) with the existing STG-025/STG-026. The handle shows the same data as a read-write open: a WAL left by an earlier session is applied in memory but not checkpointed or deleted, and a format v7 file is read into memory instead of migrated. A missing file is the newSTG-042(nothing is created).transact,retract,begin_write,checkpointandrebuild_indexesfail with the newAPI-014; queries, cursors,fact_log,verifyand rule registration work, and dropping the handle does not checkpoint.page_cache_sizeapplies as usual, so a tool can keep the whole file resident. Rust API only for now; the bindings follow. - Cursor API:
Minigraf::query()andPreparedQuery::query()(#432). Both return aCursorthat hands a query's rows back in batches:vars(),next_batch(max_rows) -> Result<Option<Batch>, _>,close(), and an iterator over rows. The answer is fixed when the cursor opens, so writes that commit while it is open do not change it. A cursor borrows nothing from the database handle: it can outlive it and move to another thread. For now the answer is still computed when the cursor opens, under the same limits asexecute(); the API is shaped so the engine can later produce rows as they are pulled without breaking callers.query()rejectstransact,retractandrulewith the newAPI-012, and a query with bind slots withAPI-010.execute()is unchanged. Like a fact log, a cursor pins the committed generation it read (#543): checkpoints andrebuild_indexes()run while it is open but do not reuse that generation's pages, so on a database taking many writes the file grows by the pages checkpoints replace until the cursor is closed or dropped.BrowserDb.query()cursors do the same: the pages they hold keep their bytes in memory and IndexedDB until a later commit after the cursor closes. Rust API only for now; the bindings follow. Minigraf::verify()andMinigraf::rebuild_indexes()(#373). Page checksums turn a torn or rotted page into an error, but they cannot see a page that is valid and says the wrong thing.verify()walks every committed page and returns anIntegrityReportwith one structured error per finding: an index that holds different facts than EAVT or a different count than the meta page (newSTG-038), keys out of order or a page reached twice (newSTG-039), dictionary maps that disagree (newSTG-040), ids with no dictionary entry (STG-036), and leaked, doubly used or wrongly free pages (STG-035). It keeps no per-fact state: indexes are compared by an order-independent digest.rebuild_indexes()rebuilds EAVT, AEVT, AVET and VAET from an intact index that agrees with another one (or the only intact one), derives the free list again, and commits like a checkpoint, including uncheckpointed writes; a crash at any point leaves the previous checkpoint. If no index can serve as the source, or the dictionary is damaged, it fails with newSTG-041and writes nothing. Both are Rust API only for now; the bindings follow.BrowserDb.prepare(#181). The browser binding gets prepared queries:db.prepare(text)parses and validates a(query ...)once and returns aBrowserPreparedQuery, whoseexecute(bindings)returns the JSONexecutereturns and whosequery(bindings)returns aBrowserCursor. Bindings are an object of slot name to a plain value (a boolean; anumber, an integer whenNumber.isIntegeraccepts it and a float otherwise; abigint; a string;null) or a typed{ type, value }form for the other kinds (floatwith no fractional part,keyword,ref,entity,txCount,timestamp,anyValidTime). A value that is not a bind value is aTypeError, and a typed value that does not fit its type (a bad UUID, a negative tx count) isAPI-017. The other bindings follow.- Prepared queries in every binding (#181).
prepare(text)returns a prepared query withexecute(bindings)(the JSONexecutereturns) andquery(bindings)(the binding's cursor) in Python, Java, Android, Swift, Node.js and C, asBrowserDb.preparedoes in the browser. Bindings map slot names (without the$) to plain host values (booleans, integers, floats, strings, null/None/nil, and a UUID type where the language has one, which binds an entity) or to a typed bind value for the other kinds (keyword, ref, entity, tx count, timestamp, any valid time). A prepared query sees the database as it is on each run. Where the binding has noclose(Python, Java, Android, Swift, browser), a prepared query keeps its database open; in Node.js and C, closing the database releases its prepared queries, so the path can be reopened, and a prepared query used afterwards fails withAPI-018. BindValue::EntityandBindValue::Val(Value::Ref(..))fit each other's slots (#181). An entity slot ([$e :attr ?v]) takesVal(Value::Ref(uuid)), and a value, expression or rule-argument slot takesEntity(uuid)as the ref. Before, each wasINT-035in the other's position, and a rule argument neededVal(Value::Ref(..)). A binding can now pass a host UUID without knowing where its slot is.
- Recursive rule joins reuse narrow entity probes (#672). A rule query keeps up to 64 complete, at most 128-row entity probes in a metered LRU cache for each join. Repeated paths through the same middle nodes read their stored edges once per join instead of once per candidate. High-degree probes still stream, and ordinary joins keep their previous path. On the bench machine,
reach_5mfell from 302.66 to 70.91–74.40 seconds in two change runs, with 165–172 versus 172 bytes of added RSS per derived fact. Query answers, file format and public API are unchanged.
- Window partition scratch obeys
query_memory_limit(#765). Each window now charges its partition row indices while they coexist with input rows and earlier result columns, returnsQRY-012if reserving them fails, and releases the charge after the window or on error. An in-place total sort removes the stable sort's uncharged merge buffer; partition and row order are unchanged. - An expression's
=and!=equate a keyword with a ref to the entity it names (#744). Patterns, joins,notbodies andorbranches treat the keyword:bobas equal to a ref to the entity:bobnames, but[(= ?g :bob)]compared the two values as they are, so with?gbound to that ref it kept no row and[(!= ?g :bob)]kept the row: the same question answered differently by how the query was written, with no error.=and!=now compare as joins do, in queries, rule bodies,notclauses and binding expressions, against a keyword constant, a keyword value or a boundVal(Ref). A keyword still equals only the same keyword, and<,>,<=and>=on a keyword or a ref are still a type mismatch (no row). checkpoint,verifyandrebuild_indexesinside aWriteTransactionreturnINT-001instead of deadlocking (#734). Called on a thread that holds aWriteTransaction, they waited on the write lock that the transaction already held, so the thread hung. They now returnINT-001, asexecute,query,fact_logandbegin_writealready did, and the transaction stays usable.prepareandPreparedQuery::execute/querymake the same check; they could not deadlock, but they ran inside the transaction whereexecute/queryrefused.checkpointdeadlocked in every release sinceWriteTransactionwas added; the fix for it and for the prepared calls ships in v2.x too.- A float constant in a pattern matches equal floats only (#706). A float written in a pattern's value position, or bound to a
$slotof a prepared query, matched every stored float within an absolutef64::EPSILON(about2.2e-16), so[?e :m/x 0.0]returned an entity whose:m/xis1e-17, while[?e :m/x ?v] [(= ?v 0.0)]did not. Above 1.0 the epsilon is smaller than the gap between adjacent floats, so only floats smaller than about2.2e-16in magnitude were affected: every one of them matched0.0, and1e-20matched5e-20. Float constants now compare by IEEE==, as joins, predicates and the indexes do:0.0matches-0.0and nothing else changes. The fix covers patterns in queries, rule bodies andnot/orbodies, on the executor and the streaming engine. Nothing stored was wrong. The same fix ships in v2.0.10. - WAL entries of more than 1,000,000 facts replay (#686). A WAL entry of more than 1,000,000 facts was taken as damage: replay stopped there, and the next open cut the WAL off at that entry, so after a crash, kill or close without a checkpoint that transaction and every later one in the WAL were lost, though each had committed. The writer had no such limit. Since #550 no v3 transaction writes such an entry, but a WAL left by v2.x can hold one. Replay now takes an entry's fact count as damage only when the bytes after it cannot hold that many facts. Affected every release since v0.5.0. Also fixed in v2.0.10.
- A recursive rule query no longer joins a rule's new facts with every magic-set fact (#694). On the engine (#548), each round after the first runs a recursive rule from the facts the last round derived, then joins the rest of its body. The rest kept the order the planner chose for the whole body, so the next clause could share no variable with the new facts: after the magic-set rewrite of
(reach :n0 ?to), the body(magic ?from) [?from :next ?mid] (reach ?mid ?to)joined each newreachfact with everymagicfact, then probed:nextonce per pair. The rest of the body is now planned with the new facts' variables bound, each next pattern sharing a variable with those bound before it when one can. Answers are unchanged. Nightlyrecursionbenchmarks (bench profile):fanout/w10_d34.94 s → 22.6 ms (it was about 94 ms on the executor before #548),chain/depth_100699 ms → 41.5 ms,chain/depth_101.15 ms → 0.68 ms. - A join on a bound value probes the index instead of reading the whole attribute (#721). #694's fix ordered a recursive rule's delta run so that the next pattern shares a variable with the new facts, but when that variable is the pattern's value (
[?x :manager ?y]after the delta(boss ?y ?z)) the join was aHashJoinholding every:managerfact, rebuilt every round:(boss :leaf ?b)over a management tree took 1.8 s at 1M facts and 35 s at 10M instead of 2–3 ms. Such a join, on a constant attrib 10BC8 ute, is now anIndexNestedLoopJoinon the value: per row, the AVET ranges of the values that join-equal it (a keyword and a ref to the entity it names, either way;0.0and-0.0) and the attribute's uncheckpointed facts with those values, read once per query. A keyword's entity is a hash of its name, so a row whose value is an entity finds the keyword that names it only where the query made the entity from that keyword (a rule head, or a keyword its rule calls name); otherwise, on an attribute with keyword values, the join becomes the hash join it was before. Answers are unchanged. Measured with the #394 envelope (bench machine, p50 and peak RSS at 100K / 1M / 10M facts):recursive_ancestors100 ms / 1.80 s / 35.0 s → 1.1 / 1.4 / 1.9 ms, 610 MiB → 4 MiB at 10M;join_3_selective([:p :manager ?b] [?s :manager ?b] [?s :name ?n]) 10.4 ms / 144 ms / 2.36 s → 0.21 / 0.20 / 0.20 ms; other shapes unchanged. The value-probe state is made only by a query that probes by value, and a scan with no uncheckpointed record allocates no shadow set: kept inline, the state had grown every query's stream past the 1,032 bytes glibc serves from its per-thread cache, and with the extra allocation a point query took about 1 µs (5%) longer. Nightlyrecursionbenchmarks (in memory, bench profile): the newancestors/w10_d497.7 → 15.1 ms,chain/depth_10041.4 → 20.8 ms,fanout/w10_d322.7 → 20.8 ms. In memory every fact is uncheckpointed, and the first value probe reads the attribute's uncheckpointed facts once per query (12 ms for 11,110 facts), soancestorsstays above its 0.27 ms before #694. BrowserDberrors carry their error code (#673). The browser API (@minigraf/browser) rejected with the error's top-level message only, such asUnknown command: nosuchcommand, while every other API reports[PRS-001] Unknown command: nosuchcommand. EveryBrowserDbrejection now goes throughMinigrafError, so it starts with its code. Host errors that have no code yet (no IndexedDB, an import blob whose length is not a multiple of the page size) are unchanged.- Prepared queries use the database's rule limit (#549).
PreparedQuery::executeandqueryran a rule query with the defaultmax_derived_factsof 1,000,000 whateverOpenOptionsset; they now use the database's limit, asexecutedoes, and itsquery_memory_limit. - An
orbranch in a rule body knows the registered predicates (#548). A rule withor/or-joinevaluated its branches with the built-in functions only, so a predicate registered withregister_predicatein a branch ((or [(big? ?v)] [?x :special true])) was unknown there. Branches now get the database's functions, as the rest of the rule body does. - A pseudo-attribute in a rule body reads the stored fact (#661). In a rule without
not,not-join,ororor-join,:db/valid-from,:db/valid-to,:db/tx-countand:db/tx-idread the rule evaluator's working copy of the fact: valid-from was the time the query ran, valid-to forever, and the tx-count and tx-id those of the copy. With[:a :edge :b]valid from 2020-01-01 to 2021-01-01,(rule [(vf ?x ?t) [?x :edge ?y] [?x :db/valid-from ?t]])returned the query's time for:a; it now returns 2020-01-01, as the same pattern in a query body does. Stored data was never affected. Present since v0.15.0; fixed on v2.x too. - Rule queries with a literal argument return every row (#608). Magic-sets rewriting restricts a rule to the facts its callers demand, but it took each rule's bound arguments from the first call it saw and assumed every other call matched. A call that did not — the same rule called with free arguments elsewhere in the query, a call inside
orornot, a call from a rule the query calls with free arguments, or a rule-body call whose argument nothing bound yet — either failed withINT-022((chain ?x :c)with(chain ?x ?y) :- (link ?x ?m) (chain ?m ?y)) or silently returned too few rows ((link ?x :c) (link ?p ?q)returned only the:cedges for?p ?q). A query with both arguments literal next to another rule call also lost its rows. The demand on each rule is now the one every call site supplies, and a rule some call site reads in full is evaluated in full. Present since v1.2.0. - A
:db/valid-atpattern in anot-joinbody binds the query's valid time on every path (#615). When the body also held an expression, sat in anorbranch, or ran in a query that invokes a rule, the body boundnil, so(not-join [?e] [?e :db/valid-at ?t] [(> ?t 0)])never matched and excluded nothing; without the expression the same body excluded every entity. It now binds the query's:valid-atvalue (or the current time) as the other paths do. Present since v0.13.1; v2.x is affected too. - A
:db/valid-atpattern in a rule definition binds the query's valid time (#617). The rule evaluators never received it, so in a rule body the pattern boundniland the query failed withINT-022; in a rule'snot/not-joinbody it boundnil, so the negation never matched on it; and in a rule'sorbranch it bound the current time instead of the query's:valid-at. Rule bodies, their negation bodies andorbranches now bind the query's:valid-atvalue, the current time without one, andnilunder:any-valid-time, as patterns outside rules do. v2.x is affected too. - An
or/or-joinbranch whose expression reads an outer variable matches (#620). A branch such as[(> ?v 1)]in[?e :n ?v] (or [(> ?v 1)] [(< ?v 0)])was evaluated on its own from an empty binding and joined back afterwards, so its expression saw?vunbound and the branch matched nothing: theorsilently dropped rows it should keep, in queries and in rule bodies. A branch whose expression reads a variable the branch does not bind itself is now seeded with each incoming row, as branches withnot/not-joinalready were. - An expression reading a variable only an
orbinds keeps its rows (#625). The planner places expressions among the patterns andnots, andor/or-joinrun after them, so an expression such as[(> ?x 1)]in[?e :n _] (or [?e :n ?x] [?e :ok ?x]) [(> ?x 1)]ran before?xwas bound and dropped every row, on the executor, on the streaming engine and in rule bodies. Such an expression now runs after theors, before thenots that wait for them, as thosenots already did. - A rule calling a rule with
not,not-join,ororor-joingets all its rows (#628). Stratification put a rule in the same stratum as the rules it calls, and within a stratum the rules without those clauses run first, so(rule [(st ?m ?s) [?m :msg/actor ?a] (cur ?a ?s)])ran before anycurrow existed whencurhad anot-join, and returned nothing; a caller that had anotororof its own ran before or after its callee depending on the query. A rule now sits in a stratum above any rule with such a clause it calls, unless the two call each other. - Recursion through a rule with
not,not-join,ororor-joinreturns all its rows (#633). Within a stratum such a rule ran once, after the rules without those clauses had reached their fixpoint, so its rows never fed back into the recursion:(rule [(reach ?x ?y) [?x :e ?z] (reach ?z ?y) (not [?y :blocked true])])stopped after one step and returned 7 of 10 pairs on a four-edge chain, with no error. A stratum whose rules call such a rule in it now repeats until that rule derives nothing new, bounded by the rule limits (INT-020), and the rule no longer adds a row it already derived.max_resultsnow counts every fact the rules derive in one evaluation, across strata and repeated passes; it used to count each stratum's rules without those clauses separately and not the rules with them at all. - An
orthat shares no variable with the earlier clauses returns its cross product (#610). Such anor—[?x :edge ?z] (or [?p :edge :b] [?p :edge :c])— returned no rows when no branch hadnot,not-joinor a pseudo-attribute, because the incoming rows were replaced by the branch results. Each incoming row now joins every branch row, in queries and rule bodies.or-joinstill refuses a join variable no earlier clause binds (INT-018): it filters, it does not bind. - A prepared query keeps a literal
:as-of,:valid-ator:any-valid-time(#603). Binding a prepared query's slots dropped any:as-ofor:valid-atthat was not itself a slot, soPreparedQuery::executeandPreparedQuery::queryran at the current transaction time and valid now:(query [:find ?v :as-of 1 :where [:a :n ?v]])prepared returned the current rows, not those at transaction 1, and a prepared per-fact pseudo-attribute query with:any-valid-timefailed withINT-026. Slot forms (:as-of $tx,:valid-at $t) were not affected. Present since 2026-04-04; fixed on v2.x too. - The wasm32 builds are tested again, and the browser layer no longer panics or drops pages. The repo split (#231) moved the browser and WASI CI jobs to minigraf-wasm, which only builds the package, so core's own wasm tests stopped running and the wasm32 test build broke unnoticed. A new
wasm.ymlworkflow runs thewasm-bindgentests in headless Firefox and Chrome, runs the unit and integration tests onwasm32-wasip1under Wasmtime, and lints both targets withclippy -D warnings. Fixes found on the way:BrowserDbskipped any dirty page it could not read when flushing to IndexedDB and still reported success, and now returns the error (also fixed in v2.0.4, #470). The IndexedDB upgrade callback, the IndexedDB backend handle and the page-buffer lock no longerunwrap()(on wasm32 a panic aborts the whole instance). IndexedDB keys that are not whole page numbers are rejected instead of truncated. The nineBrowserBufferBackendtests used#[test], which the wasm test runner never runs, and now run. File-only modules (wal,storage::verify,storage::dir_sync) are no longer compiled for wasm32, so the wasm32 builds have no warnings. BrowserDbstores upgraded pages and keeps only live pages (#411, #440).BrowserDb.open()now writes the pages an open produced to IndexedDB, so a v7 store is upgraded to v8 once instead of in memory on every open. Pages on the free list no longer keep their bytes in memory or their keys in IndexedDB: open drops them (after a v7 upgrade, the whole old v7 region), and eachtransact,retract,checkpoint()andimportGraph()drops the pages its commit freed, in the same IndexedDB transaction as the commit's pages and meta page. Memory and IndexedDB size now follow the live data rather than the write history, and opening no longer reads dead pages back.importGraph()also deletes the keys of the replaced database's pages, which a reopen used to load next to the imported ones.exportGraph()writes free pages as zeros and is as long as the file's meta page says; the blob opens natively as before.- Two values of the same attribute written in one transaction are both returned by every query path. Previously only one came back, and entity-bound and attribute-bound queries could return different ones (#371, #287).
- A retract that removes several values of one attribute in one call now hides all of them (#371).
- Queries no longer lose or duplicate facts when more than 65,535 facts are waiting for a checkpoint (#445; also fixed in v2.0.3).
- A file shorter than its meta page count is refused at open with
STG-044(#497). Open never compared the meta page'spage_countwith the file's length. A truncated file (an interrupted copy, a partial download) opened, and its queries and checkpoints then failed withINT-049after the WAL had accepted writes. A meta with apage_countfar past the end opened too: its next checkpoint wrote at that page id, leaving a sparse file of up to 4 EiB, andverify()scanned to that count and never finished. Open now refuses both, read-write and read-only, without modifying the file. It does not fall back to the other meta page, because a valid newer meta means that checkpoint happened. A checkpoint now fails withINT-049instead of writing a meta that counts pages the backend does not hold, andverify()reports apage_countpast the end asSTG-044and stops its page scan at the end of the file.BrowserDbis not checked: it keeps only live pages (#440), so its store has no length to compare. New codeSTG-044. - A WAL from a later checkpoint is refused at open with
STG-047(#535). Opening an older.graphnext to a.walcreated after a later checkpoint, as happens when a backup is restored and the current WAL is left in place, replayed the WAL on top of the older file and reported success: the later transactions were applied and the ones between the two checkpoints were silently missing, and the next checkpoint made the gap permanent. The WAL's header records the generation it was created at; open now refuses, read-write and read-only, when that is newer than every meta page in the file, and leaves both files unmodified. A WAL with an older base (a crash between a checkpoint's commit and its WAL delete) replays as before. v2.x WALs record no generation, so v2.x cannot detect this. - A file shorter than one page is refused at open with
STG-045unless it is a torn first write (#506). Open treated any file under 4 KiB as a new database, because it holds no whole page, and a read-write open wrote a new empty database over it. A database cut below one page by an interrupted copy, or an unrelated file at the path, was silently replaced. Now such a file opens as a new database only when its bytes are zeros or a prefix of the first meta page a new database writes (what a crash during creation can leave, the rule a torn full page 0 already followed); anything else isSTG-045, read-write and read-only, and the file is not modified. A zero-length file is still a new database. New codeSTG-045. Also fixed in v2.0.5. - A failed checkpoint is never retried on the same handle (#390). A checkpoint that failed after it may have written (a failed write, a full disk, a failed
fsync) could be retried by the next checkpoint. After a failedfsyncthe operating system may have dropped the written pages, and the checkpoint's meta page may have reached the disk although the call failed; a retry could then overwrite pages that meta references, and a crash during the retry left a file that failed to read withSTG-029. Now every later write, checkpoint and index rebuild through that handle fails with the newSTG-046, queries keep working, and the close-time checkpoint is skipped. Reopening the database recovers everything committed: it is all in the WAL. Arebuild_indexes()refused withSTG-041writes nothing and does not count as failed. A fault-injection matrix now covers EIO, torn and short writes, a full disk and failed fsyncs at every write and sync of transacts, checkpoints, the close-time checkpoint and index rebuilds. - A commit made after a torn WAL entry is no longer lost on recovery (#513). Recovery replays the WAL up to its first partial or damaged entry, but the writer appended after the end of the file. After a crash during an append, the next session's commits went in after the partial entry, so a second crash before any checkpoint lost them, although each had been fsynced and reported as committed. A failed append (short write, full disk, I/O error) did the same to the next commit through that handle. Opening a database for writing now cuts the WAL back to the end of its last valid entry, and syncs it, before anything is appended. After a failed append, every later write through that handle fails with the new
WAL-007until the database is reopened or acheckpoint()succeeds. The transaction whose append failed is not applied; after a reopen it is either there in full (its entry reached the disk) or not at all. Affected every release since v0.5.0. Also fixed in v2.0.5. - A v7 fact page with a damaged type byte fails the upgrade with
STG-014(#496). Opening a v7 file skipped, without an error, any page in its fact-page range whose type byte was not the fact-page type. The read-write open then upgraded the file to v8 and freed the old v7 pages, so that page's facts were lost for good; the read-only open returned the same answer with them missing. A v2.x writer fills that range with fact pages only, so any other type there is damage: open now fails withSTG-014, read-write and read-only, and leaves the v7 file as it was. v7 fact pages have no checksum of their own, so damage elsewhere in a page can still decode to wrong facts or fail with another code; keep a copy of a v7 file until it has opened under v3.0.0. - Rule queries no longer fail with
INT-020on databases with 1,000,000 or more live facts (#528). The recursive evaluator counted the database's own facts againstmax_results, so once a database held that many live facts, every rule query failed at its first derived fact, whatever the size of its answer.max_resultsnow counts only the facts the rules derive, so the:max-results 3000000workaround is no longer needed. Affected every release since v0.18.0. Also fixed in v2.0.5 (#538). - Reopening next to a WAL whose entries were all already checkpointed no longer rewinds the transaction counter, so later transactions no longer reuse
tx_countvalues and:as-ofhistory stays correct (#447; also fixed in v2.0.3). checkpoint()removes a WAL whose entries are all already in the file (#457). When it had no facts to flush, it returned early and left the WAL until a later write was checkpointed. That WAL is left by a crash between a checkpoint's commit and its WAL delete, or by a failed delete (WAL-006). Its contents were always skipped on replay, so no data was affected; the sidecar file just stayed. Also fixed in v2.0.5 (#540).- Closing a handle opened with
wal_checkpoint_threshold(usize::MAX)no longer writes its pending facts to the.graphfile (#476). The sentinel was documented to suppress the close-time checkpoint, andMinigraf's close did skip it, but the storage layer still saved on drop, leaving a WAL whose entries were all checkpointed. The file now stays as the last explicitcheckpoint()left it, and the WAL keeps every pending fact for the next open to replay, so bulk loaders and benchmarks see the pending WAL they ask for. No data is lost either way. Also fixed in v2.0.5 (#540). - A query on another thread sees each transaction whole (#554). A commit wrote its WAL entry, then added the transaction's facts to the in-memory store one fact at a time, each under its own lock, so a query running on another thread could return part of the transaction.
execute("(transact ...)"),execute("(retract ...)")andWriteTransaction::commitall did this. They now add the whole transaction under one lock, as does WAL replay. Stored data was never affected. AffectedWriteTransactionsince v0.5.0 andexecutesince v0.6.0. - All patterns of one query read the same state of the database (#555). A query whose patterns name entities or attributes read each of them with its own lookup, each under its own lock, so a transaction that committed between two lookups was seen by some patterns and not others, and the query could return rows that match no state of the database (an entity with one attribute but not another that was always written with it). The lookups now run under one lock. Stored data was never affected. Affected every release since v1.1.0.
- A query never sees a checkpoint half published (#543). A checkpoint cleared the in-memory pending facts and installed the new committed index as two steps, so a query between them could miss the facts being checkpointed or, with the opposite order, see them twice. Both now change under one lock. Every read of one query, cursor or fact log is taken from one snapshot of the database, which holds the committed generation it read until it is dropped. This affected only v3.0.0 development builds; in released versions the checkpoint race is #558, fixed separately.
- A damaged index tree whose node points back up fails the query with
INT-049instead of never finishing (#597). A scan resumes each batch after the last key it read. When a corrupt internal node led back to a leaf already read, the scan read the same keys again on every batch and never ended, holding more memory each time; the nightly fuzz run found it as an out-of-memory. The B+tree cursor now requires keys to increase from one leaf to the next and never to fall below its start or the key it was last moved to, and refuses an empty leaf below an internal node; any of these fails withINT-049.verify()already reported such a tree. - A fact with more than four elements is rejected (#649).
transactread a fact's 4th element as its optional valid-time map and ignored everything after it, so[[:alice :person/age 1 {} 99 :junk]]stored[:alice :person/age 1]and dropped the rest without an error; two facts run together by a misplaced bracket lost one. A fact of more than four elements now fails with the newPRS-108, and the transaction writes nothing. A fact of five or more elements whose 4th is not a map now getsPRS-108instead ofPRS-047. Present since v0.4.0; found by the grammar verifier (#643), whoseFactAritydrift entry is removed.
- Projected entity UUIDs share DICT tree reads (#530). Projection resolves up to 1,024 committed ids together, walking dense dictionary ranges with the existing leaf cursor and using scalar exact reads for sparse/small batches. Bounded stack hints keep query-cache insertion and memory charges in projection order; pending ids stay in memory, failed speculative reads fall back to scalar conversion, and each pull still releases committed lookup maps. No file-format or configuration change. The focused 1M-fact scan has 48.2% lower p50 latency (262 → 136 ms);
docs/BENCHMARKS.mdrecords paired runs, cutoff costs and repeated-ref variability. - Committed reads hide superseded history on index keys (#379). A query's committed facts are now reduced to the ones that survive net-assert (each fact's assertion from its newest transaction, unless that transaction retracted it; #435) while walking the index keys, before any fact is built. Only surviving entries are decoded and translated through the dictionary. A transaction that retracted a triple hides everything older in it, and a long run of hidden entries is skipped with one
seek.:as-of Npoint and attribute queries now take the same index path instead of reading every fact in the file. On a checkpointed file with 2,000 superseded values, a point query takes 1.03 ms instead of 4.61 ms, an attribute scan 1.24 ms instead of 5.87 ms, and an:as-ofpoint query 23 µs instead of 5.6 ms (at depth 1). Results are unchanged: the executor still applies net-assert to committed and pending facts together, and a pending fact is always newer than a committed one. - B+tree reads no longer follow leaf sibling links (v8 storage format, PR 1 of 5; #374). Every index scan uses a cursor that walks down from the root and keeps the path, with a forward
seekthat climbs only as far as needed. This prepares for copy-on-write index pages, which cannot keep sibling links. The file format and query results are unchanged. Concurrent 100k-fact scans take 25–37 % less time and checkpointing after one fact at 10k facts 9 % less; single point lookups and attribute scans are 2–6 % slower (about 0.4 µs per lookup). A corrupted child pointer now fails with STG-013 (wrong page type) or INT-049 (cycle) instead of following a bad link.
-
Deterministic storage schedule tests (#760). Three separately enabled native tests explore transaction append, snapshot cutoffs and checkpoint publication with Shuttle, using the real storage methods and immutable committed-reader fixtures. Deliberate fault patches validate failure detection and schedule replay. Focused PR checks cover Linux, macOS, Windows and Linux MSRV; nightly/manual runs explore more schedules and retain replay inputs. Findings and reproduction describe the limits; ordinary tests and production builds retain
stdsynchronization. -
Format-v8 scalar key decoders are model checked (#693). Pure inner decoders let Kani prove round trips for signed and unsigned integers, descending transaction counts, valid-to times, floats and six tagged scalar value kinds, plus no panic and bounded cursor consumption on short malformed inputs. The
Readerstill returns the same INT-049 messages and cursor positions; a test-only copy of its old code checks those against exhaustive 0–2 byte and seeded longer inputs. Strings, sets, packed pages and WAL retain fuzz and property coverage. No key bytes or file format change. -
Statement bases and large write transactions (#550). A
WriteTransactionwhose statements repeat, change, retract and re-assert each other's facts across chunks gives every statement the same outcome, and stores the same records, at chunk sizes 1 to 4 as with one chunk; queries after each statement return what a commit of the statements so far would hold; failed and rolled-back statements and transactions leave the file passingverify. The fault matrix covers a largeWriteTransactionat every write and sync, and withtest-hooksthe crash-kill test runs every third transaction of its large variant as one. -
Golden-file compatibility corpus (#391).
tests/golden/holds v7 files written by minigraf 2.0.3, each with a JSON manifest of its CRC32,tx_countand expected query results. They cover one checkpoint, several checkpoints, indexes rebuilt on open, a WAL left by a crash, a WAL whose entries were already checkpointed, and same-transaction multi-values (#371).tests/golden_corpus_test.rsopens a copy of each file and checks its manifest after open, after a checkpoint and reopen, and after one more write. Golden files are never regenerated, only added; a newgolden-corpusjob inpolicy.ymlfails a pull request that changes one. The generator is a standalone crate pinned tominigraf = "=2.0.3"(tests/golden/gen/v7/). Onv3, each v7 file must also carry a v8 meta page after its first open, and seven v8 files are frozen alongside them, written by thev3branch at23aa57c(tests/golden/gen/v8/): one checkpoint, seven copy-on-write checkpoints, a crash-left v2 WAL, an already-checkpointed v2 WAL, same-transaction multi-values, a v7 file migrated in place, and long values (a 4,068-byte string stored twice, the 64/65-byte inline boundary, a 1,000-byte attribute and keyword). All #371 queries now pass. -
Model-based storage test (#385).
tests/model_based_test.rsruns random sequences oftransact(multi-valued batches, transaction- and fact-level valid-time windows, a triple repeated with another window),retract, write transactions (committed or rolled back, retracting their own assertions), checkpoints, reopens (default, a 4-page cache, auto-checkpoint every 2 writes) and simulated crashes (a copy of the.graphfile and WAL taken while the handle is open, so the WAL is replayed) against a file in a temp dir. After every step,current_tx_count,verify, full rows with windows and tx counts, and entity-, entity+attribute-, attribute-, attribute+value-, value-bound and full-scan queries, under:as-of Nand:valid-at T, must match a plain reference model (one valid-time window per triple, #435; API-011 for two windows of one triple in one statement and API-019 for an empty or inverted window, fromexecuteandtx.execute, #436; the last statement that writes a triple decides it at commit, #477). Reads inside a write transaction are checked against what its commit will hold. Normal CI runs 24 cases; the nightly property-test workflow runs 30,000. -
SIGKILL crash test checks committed data (#384).
tests/crash_kill_test.rsused to check only that the file reopened and one attribute query ran, so it missed #370 and could not reach #371. The child now runs a seeded workload (multi-valued batches, batched retracts, re-assertions) and logs each transaction afterexecutereturns; it checkpoints after every transaction, every third, never (WAL replay), automatically every 2 writes, or every third withSyncMode::Normal. After aSIGKILLat a random point, the reopened file must hold exactly the model after the last logged transaction or the one in flight, through a full scan, attribute (AEVT), attribute+value (AVET) and entity-bound (EAVT) queries and:as-of, withverifyclean. The checks run again after a second reopen and after a checkpoint plus reopen. Per-PR CI runs 5 rounds (one per mode); the nightly crash-kill workflow runs 100 per OS.MINIGRAF_CRASH_KILL_SEEDreplays a failure. -
Fuzzing reaches the v8 decoders, and the corpus is kept between runs (#387, #375). On
v3, thebtree_pageandfact_pagetargets still wrote the pre-release single-header layout and stopped atSTG-032on every input, andfile_headerrarely got past the meta CRC. The page targets now start from a real v8 file built through the public API (rewritten withLogWriterat fixed tx times so its bytes are the same in every process). They overwrite one page with the input and recompute that page's checksum.btree_pagefuzzes any leaf or internal node of the five trees,file_headerfuzzes either meta slot (with or without a matching CRC, with or without the other slot), andfact_pagefuzzes the fact page of a v7 golden file through migration and the read-only load. Each run executes one input-chosen probe query orverify(). The nightly fuzz workflow now runs every target in its own parallel job, onmainandv3on alternating crons, and keeps each target's corpus in the Actions cache. A new weekly workflow (fuzz-corpus.yml) minimizes the cached corpus withcargo fuzz cmin, keeps every committedseed_*file, and proposes the result as a pull request. -
Two fuzz targets check results, not just crashes (#387).
ops_sequencedecodes the input into up to 32 storage operations (transact, retract, a write transaction that commits or rolls back, checkpoint, reopen, and a crash that opens a copy of the file and WAL) and, after each one, compares the full rows now and at an input-chosen:as-of, plus one input-chosen query shape, with the reference model of the model-based test. It uses that test's driver and checks, now intests/reference_model, so the proptest test and the fuzzer share one oracle.corrupt_filedamages a file written over three checkpoints with up to 16 byte flips and an optional truncation, without fixing any checksum, and requires every query to fail or to return exactly the answers of the last checkpoint or the one before it, all from the same one. Its first run found that a file cut to under one page opens as a new, empty database (#506); the target skips those lengths until that is fixed. -
Weekly soak test at scale (#392).
tests/soak_test.rsgrows a file-backed database to 10M live facts in batches of 1–2,000 facts (multi-valued tags and shard values in one transaction, strings over 64 bytes, refs), then churns it for the rest of a 5-hour budget at 50 transactions a second: retracts and re-asserts of random facts, and long retract/assert histories on 32 hot entities. It checkpoints every 20–200 transactions and reopens every 2–10 minutes, half from a checkpointed file and half replaying the WAL. Every 10 minutes, after every reopen, and between churn writes it checks samples against a reference that derives each entity's facts from its number and keeps only what was retracted: EAVT per entity, entity and attribute on the tags, AVET by unique name, AEVT over a sparse attribute,:as-ofon hot entities, and the transaction counter; a full scan runs in a child process at 2M facts (so its memory does not hide a leak), andverify()at the end. It fails if RSS after a reopen grows past 1.25× the baseline plus 256 MiB. Metrics (file size, open and checkpoint time, RSS and peak RSS, p50/p99 per query path) go to a JSON-lines file that.github/workflows/soak.ymluploads weekly, alternatingmainandv3.soak_shortruns the same driver at 20K facts in the per-PR suite. Found #518: attribute-and-value patterns scan the whole attribute. -
Coverage gates that gate (#396). The per-module gate in
coverage-gates.ymlhad never checked anything: the instrumented test run failed (the lock tests could not findpid_ns_helper), the report it grepped was empty, and every module was skipped with a warning, so the job passed. It also readcargo llvm-cov --text, which has no per-file percentages, and namedbtree_v6, which v3 no longer has. The job now runs the whole suite with branch coverage on a pinned nightly, fails if any test fails, and checks each file's line and branch percentage againstcoverage-thresholds.tomlwithscripts/coverage_gates.py; a file missing from the report, or without branch data, fails. The thresholds file covers the WAL,db.rs, the fact log and log writer, every storage file, and the evaluator, executor and stratification, with floors at the measured values less one point. It also lists the paths left uncovered on purpose (invariant checks, browser-only and Windows-only paths). New tests cover the WAL's fact-size and fact-count limits (the old fact-size test changed bytes inside the fact and never reached the limit), a WAL opened in a missing directory, a failed cut of a torn WAL tail, short v7 pages and slots past the count, backup meta pages of another generation or length, a torn first meta with data in page 1,savewith nothing pending,into_backendwith pending facts,rebuild_indexeswith nothing checkpointed, unsorted or repeated B+tree input, a stored key inserted with another value, a tree pointer to a page of another type, the read-onlyMutexStorageBackend, and two threads missing the same page in the page cache (before, only a lucky race reached that path). Branch coverage ofwal.rswent from 83% to 95%,packed_pages.rsfrom 86% to 100%,persistent_facts.rsfrom 82% to 88%, andbtree.rsfrom 85% to 85.5%. A follow-up brought the storage files the issue named to 90% branch coverage or more:btree.rsto 95.7%,persistent_facts.rsto 91.7% andindex.rsto 100%, with floors raised to match. New tests cover a leaf packing retried around one large entry, an entry larger than a page, separators over the fill target, an insert into a tree whose root points to itself, auto-save on drop, a torn first meta with a blank page 1, and an unreadable page past the meta's page count. Splitting internal nodes is now one pass: the old retry loop could not run, because the balanced cut always leaves each node within the fill target. The workflow pins cargo-llvm-cov 0.9.1, since 0.8.x missed the library's unit tests under the pinned nightly and reported much lower numbers. -
Performance envelope at 100K–10M facts (#394).
examples/perf_envelope.rsbuilds and caches fixtures of 100K, 1M and 10M facts (six attributes per entity, a ref tree, two valid-time windows, an entity with 1,000 versions). It reports open and checkpoint times, RSS, and p50/p99 latency for twelve query shapes, each shape in its own process. It builds with a newenvelopeCargo profile (releaseat opt-level 3, as an application compiles the library;releaseitself is opt-level "z").docs/BENCHMARKS.mdand the README publish the results: point lookups and single-entity time travel stay flat (12–14 µs at 10M facts). Scans, aggregations and joins grow linearly with the attributes they touch. Rule queries are supported to 100K facts only. A Python FFI round trip (bench/envelope.pyin minigraf-python) adds about 70 µs per query..github/workflows/envelope.ymlruns 100K and 1M on demand, and a 10K smoke pass on PRs that change the harness. The nightly Criterionquery/*groups gain 100K and 1M scales. Profiling at 1M facts found #528, #529, #530, #531 and #532. -
Pseudo-attribute sources (#594).
tests/pseudo_source_test.rscovers the source rule (nearest earlier pattern in the text, two pseudo-attributes sharing one, every fact through[?e ?a _], constants by entity id,$slots by name),PRS-081for each shape with no source, reading the outer fact fromnot,not-join(listed and unlisted variable),or(never a sibling branch) andor-join, closed rule bodies, and:db/valid-at. Parser, optimizer and matcher unit tests cover the hidden variables, their placement and the join variables a crossednot-joingains. The plan-independence matrix gains the pseudo-attribute shapes. -
Answers that depend on the plan fail the tests (#596). A test-only switch (
minigraf::__reorder,test-hooksfeature) turns off every place the native build reorders: the pattern selectivity sort, thenot/not-joinandor/or-joincost sorts and theorshort-circuits (#250), and the engine'sHashJoinbuild side.tests/plan_independence_test.rsruns a matrix of queries (joins, expressions, rules,or/or-join,not/not-join, keyword and ref, floats, long strings, aggregates, time travel), in memory and on file, throughexecuteand a cursor, with the switch on and off, and requires the same rows. Undertest-hooksthe model-based (#385) and property (#386) tests also run every generated query with the switch off. -
Rule queries on the engine (#548).
tests/recursive_engine_test.rs(10 tests) runs rule queries in memory, on a file with pending facts and after a checkpoint, checked against the executor until #551 and against its recorded answers since: transitive closure (linear and non-linear), mutual recursion, strata whosenotandorrules recurse, rules that read their stratum outside a top-level invocation, derived facts seen by every pattern, the identity of derived facts,nilmetadata, heads, bothINT-020limits, and rule queries with aggregates,not-join,orand windows. Every existing rule suite (recursive_rules_test,negation_test,disjunction_test,magic_sets_test, the model-based and property tests) now runs its rule queries on the engine with the oracle. Engine unit tests cover routing and derived rows read across batches. -
Recursive rules at 5M derived facts (#473 acceptance, #548).
tests/recursive_acceptance_test.rs(ignored; the nightly smoke workflow runs it) derives 4,882,500reachfacts in 62 rounds over a layered graph of 155,000 edges and checks that every one is returned and that the query's peak RSS stays under 256 bytes per derived fact (169 measured); the defaultmax_derived_factsthen stops the same rule withINT-020. The #394 envelope has arecursiveshape: the whole transitive closure of:manager, with no constant, so no magic sets. -
Query memory at 1M groups and on the #575 product (#549).
tests/memory_limit_acceptance_test.rs(ignored; the nightly smoke workflow runs it) returns 1M aggregate groups, orQRY-012namingAggregateunder a 32 MiB limit, and streams the 1.6M rows of 13 patterns sharing no variable over the fuzz harness's 3 facts in under 64 MiB of peak RSS (the executor held more than 5.7 GB), orQRY-012namingHashJoinunder a 64-byte limit. An ignored engine test prints the meter's peak against the peak RSS: 435 MiB against about 545 MiB for the groups (docs/BENCHMARKS.md). Measuring it foundAggregate's sorted map of groups uncharged while its hash table was still held; the map is now charged and the table freed first, which lowered the real peak from 651 MiB. -
Negation bodies in both forms (#546). A second test-only switch (
minigraf::__subplans,test-hooksfeature) forces the engine'snot/not-joinbodies to run hashed or correlated.tests/plan_independence_test.rsruns every query with the body forms chosen, forced hashed and forced correlated, each with reordering on and off; its matrix gains negations over floats near zero,0.0and-0.0, keywords and refs of one entity, long strings, bodies reading an imported variable they do not bind, variable-free bodies and:db/valid-at.tests/negation_equality_test.rschecks the changed answers on every executor path, in memory and after a checkpoint. The property test's rerun also forces hashed bodies. Engine unit tests cover the form the planner picks, routing, multiplicity, failing expressions in bodies, and that a hashed body is read once and a correlated one probes once per row. -
Disjunctions (#546).
tests/or_equality_test.rschecks the changedor/or-joinanswers throughexecuteand a cursor, in memory and after a checkpoint.tests/plan_independence_test.rsgains disjunctions (keywords meeting refs, floats near zero, long strings as keys, branches naming different outer variables or no join variable, a branch withnot, anorwith nothing before it, a cross product,:db/valid-atin a branch), run with branches chosen, forced hashed and forced correlated. The property test generatesorandor-joinover bound variables, with a reference evaluator that deduplicates the rows they keep. Engine unit tests run every disjunction shape with each branch form forced against the executor, and cover routing, the form the planner picks,Distinct, and that a hashed branch is read once. -
Aggregates and windows (#547).
src/query/datalog/aggregate.rsunit tests check every accumulator over every order of small inputs (exact float sums against hand-computed values, cancellation, subnormals, signed zeros, infinities and NaN, integer saturation, errors decided at the end) and that the value order is total.tests/aggregate_window_rules_test.rschecks the changed answers;tests/plan_independence_test.rsgains aggregate, window and mixed queries over ties, keywords and refs, and floats. -
tests/snapshot_atomicity_test.rsraces a writer against a reader: a query must see each transaction whole, whether written withexecuteor aWriteTransaction(#554), and must read one state for all its patterns (#555), in memory and on file. It also runs the v2.x checkpoint race from #558, which never failed on v3: copy-on-write checkpoints do not overwrite pages a published reader uses. -
The
datalog_evalfuzz guard counts partial bindings (#597). The guard skipped inputs whose patterns join to more than 50,000 rows, counting only the rows after the last pattern. Anot-joinbody still runs on the executor pattern by pattern in text order, so a 120-pattern body built hundreds of thousands of partial bindings before its last pattern matched nothing, and ran past the 60-second timeout. The guard now counts the bindings after every pattern. On thev3corpus (4,783 inputs) coverage drops by 7 of 9,719 edges. The input is added as a seed, as is thebtree_pageinput of the cursor fix above. -
Kani proofs of the key and value encoders (#627).
src/storage/keys/verification.rsandsrc/storage/index/verification.rshold 12 Kani encoder harnesses, compiled only undercfg(kani). They prove for every input that the v8 key encoders (put_int,put_uint,put_tx_desc,put_valid_to,float_bits) andencode_valuekeep the order the indexes rely on, that every NaN encodes to one value, and thatfloat_bitsround-trips..github/workflows/kani.ymlruns them on pull requests that touch the keys and nightly onmainandv3. The original #627 scope left decoders out; #693 above adds scalar decoder proofs. Strings, sets, packed pages, WAL andReader's error mapping remain under fuzz, property and differential tests. Test-only;Cargo.tomldeclares thekanicfg so normal builds do not warn. -
Parser vs grammar verifier (#643).
src/query/datalog/parser/verifier.rschecks, for every generated input, that the parser rejects whattests/grammar/grammar.pestrejects, and accepts what it accepts unless the error code is semantic (a listed code with a witness pest accepts). It runs every string of 1 to 3 characters over 102 characters (ASCII,\t \n \r, four non-ASCII stand-ins) throughparse_edn, and every bracket-balanced sequence of up toMINIGRAF_GRAMMAR_BOUND(default 3) of 32 tokens in 15 command templates throughparse_datalog_command, also with no separators up to 3 tokens. Deterministic; about 20 s by default and 3.5 min at bound 4 on a 16-thread machine. The drift it found is listed inKNOWN_DRIFTby cause (comments and non-ASCII letters #650, since documented: see below; empty sections, ignored keywords, rule and call heads, a lone:or-, unterminated strings #651, since fixed; a fact of more than four elements losing elements, #649, since fixed); each fix removes its entry.grammar.pestnow says what the parser means: a fact is exactly[e a v]plus an optional valid-time map and a retracted fact exactly[e a v];not,not-join,or,or-join(andandinside anor) are never rule names; literal keywords end at a word boundary (:find?xis one keyword). -
The grammar documents comments and non-ASCII identifiers (#650). The parser has always accepted both;
tests/grammar/grammar.pestanddocs/GRAMMAR.mdnow say so. A;starts a comment that runs to the end of the line and is skipped like whitespace. Symbols, variables, keywords and bind slots take Unicode letters and numerals (:person/名前,?ñ), as Rust'schar::is_alphabeticandchar::is_numericdefine them; a symbol starts with a letter or_, and-followed by a numeral starts a number, not a symbol. Nothing the parser accepts or rejects changes.
- Browser WASM is a Tier 1 binding. From v3.0.0,
@minigraf/browseris tested and released with every core release, alongside the Rust crate and Python. PHILOSOPHY.md's support tiers now say that the project's own published tools count as dependents for promotion (the basis on which Python became Tier 1), that a promotion takes effect at a major or minor release, and that a new storage backend in a Tier 1 binding, such as OPFS (#441), starts at Tier 2 until it has shipped. - Generated grammar page (#643).
docs/GRAMMAR.md, published on the docs site as the Grammar page, is EBNF rendered fromtests/grammar/grammar.pest, the grammar the parser verifier checks the parser against, so the documented syntax is the tested one.tests/grammar_doc.rsrenders it withpest_meta(a new dev-dependency, already built forpest_derive) and fails when the file is stale;UPDATE_GRAMMAR_DOC=1 cargo test --test grammar_docrewrites it. A pest lookahead is shown as an EBNF comment, and rules read without skipping whitespace are marked(* token *). The hand-written EBNF in the Datalog reference stays for v2.x only. - Durability, recovery and operations guide (#398).
docs/DURABILITY.md, also published on the docs site, covers what survives a process crash and a power loss underSyncMode::FullandSyncMode::Normal, what opening after a crash does and what it costs, which filesystems and container setups are supported, how to back up a live database (offline copy, volume snapshot, orfact_logintoLogWriterfrom the running process) and restore it, how to check and repair a file withverify()andrebuild_indexes(), how to upgrade from v2.x and roll back, which storage errors mean retry, reopen or restore, and the known limitations. Writing it found #535: a.walfrom a later checkpoint is replayed onto an older.graphwithout an error, so the restore steps remove any.walfirst. A log-writer test runs the documented online backup while another thread writes past the automatic checkpoint threshold.
- Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one
WriteTransactionis lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435), andsave()is not crash-atomic (#374); all are fixed in v3.0.0. All v2.x known issues, with affected versions and workarounds, are listed in the pinned issue #421.
Patch release on the v2.x line under the support policy: two data-integrity fixes (#506, #513) and three bug fixes backported from v3.0.0 (#528, #457, #476), plus a weekly soak test and working coverage gates. File format is unchanged (v7). Two new error codes, STG-045 and WAL-007, numbered as in v3.0.0; no other API changes. Upgrading is recommended for every v2.x user.
- A file shorter than one page is refused at open with
STG-045unless it is a torn first write (#506). Open treated any file under 4 KiB as a new database and wrote a new header over it, so a database cut below one page by an interrupted copy, or an unrelated file at the path, was silently replaced. Such a file now opens as a new database only when its bytes are zeros or a prefix of the header a new database writes (what a crash during creation can leave); anything else isSTG-045and the file is not modified. A zero-length file is still a new database. New codeSTG-045, numbered as in v3.0.0, which also fixes this. - A commit made after a torn WAL entry is no longer lost on recovery (#513). Recovery replays the WAL up to its first partial or damaged entry, but the writer appended after the end of the file. After a crash during an append, the next session's commits went in after the partial entry, so a second crash before any checkpoint lost them, although each had been fsynced and reported as committed. A failed append (short write, full disk, I/O error) did the same to the next commit through that handle. Opening a database for writing now cuts the WAL back to the end of its last valid entry, and syncs it, before anything is appended. After a failed append, every later write through that handle fails with the new
WAL-007until the database is reopened or acheckpoint()succeeds. The transaction whose append failed is not applied; after a reopen it is there in full or not at all. Affected every release since v0.5.0. Also fixed in v3.0.0. - Rule queries no longer fail with
INT-020on databases with 1,000,000 or more live facts (#528). The recursive evaluator counted the database's own facts againstmax_results, so once a database held that many live facts, every rule query failed at its first derived fact, whatever the size of its answer.max_resultsnow counts only the facts the rules derive, so raising it withOpenOptions::max_resultsor:max-resultsis no longer needed. Affected every release since v0.18.0. Also fixed in v3.0.0. checkpoint()removes a WAL whose entries are all already in the file (#457). When it had no facts to flush, it returned early and left the WAL until a later write was checkpointed. That WAL is left by a crash between a checkpoint's commit and its WAL delete, or by a failed delete (WAL-006). Its contents were always skipped on replay, so no data was affected; the sidecar file just stayed. Also fixed in v3.0.0.- Closing a handle opened with
wal_checkpoint_threshold(usize::MAX)no longer writes its pending facts to the.graphfile (#476). The sentinel was documented to suppress the close-time checkpoint, andMinigraf's close did skip it, but the storage layer still saved on drop, leaving a WAL whose entries were all checkpointed. The file now stays as the last explicitcheckpoint()left it, and the WAL keeps every pending fact for the next open to replay, so bulk loaders and benchmarks see the pending WAL they ask for. No data is lost either way. Also fixed in v3.0.0.
- Weekly soak test at scale (#392).
tests/soak_test.rsgrows a file-backed database to 10M live facts in batches of 1–2,000 facts, then churns it for the rest of a 5-hour budget at 50 transactions a second (retracts, re-asserts, long histories on 32 hot entities), with a checkpoint every 20–200 transactions and a reopen every 2–10 minutes. Samples are checked against a reference through EAVT, entity and attribute, AVET, AEVT,:as-ofand the transaction counter, with a full scan in a child process at 2M facts. RSS after a reopen is bounded relative to the fact versions held. Metrics go to a JSON-lines file that.github/workflows/soak.ymluploads weekly, alternatingmain(Saturday) andv3(Sunday). On v2.x no transaction writes two values of one entity and attribute, because of the known issue #371; the v3 workload fails here. It also checkpoints every 100–1,000 transactions and samples:as-ofless, since both cost time in the database size on v2.x.soak_shortruns the driver at 20K facts in the normal suite. - Coverage gates that gate (#396). The per-module gate in
coverage-gates.ymlhad never checked anything: the instrumented test run failed (the lock tests could not findpid_ns_helper), the report it grepped was empty, and every module was skipped with a warning, so the job passed. It also readcargo llvm-cov --text, which has no per-file percentages. The job now runs the whole suite with branch coverage on a pinned nightly, fails if any test fails, and checks each file's line and branch percentage againstcoverage-thresholds.tomlwithscripts/coverage_gates.py; a file missing from the report, or without branch data, fails. The floors are the measured values less one point, for the WAL,db.rs,graph/storage.rs, the storage files (except the v5 B+tree kept for migration) and the evaluator, executor and stratification. New tests check that WAL replay stops at an entry whose first fact is over 10 MB (the old test changed bytes inside the fact and never reached the limit) or that claims more than 1M facts, that a WAL writer cannot be opened in a missing directory, and that two threads missing the same page in the page cache get the one cached copy (before, only a lucky race reached that path).
- Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one
Wri 10BC8 teTransactionis lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435),save()is not crash-atomic (#374), and a.walfrom a later checkpoint is replayed onto an older.graph(#535); all are fixed in v3.0.0
Patch release on the v2.x line under the support policy: one BrowserDb durability hardening fix, plus new compatibility and crash tests. File format is unchanged (v7). No API changes. Native and WASI builds behave exactly as in v2.0.3.
BrowserDbno longer drops a dirty page it cannot read from the IndexedDB flush (#470).checkpoint(),importGraph()and every write collected dirty pages withread_page_raw(id).ok(), so a page that failed to read was left out of the flush while the call still reported success. The read error is now returned. No current code path leaves a dirty id without a page, so this guards against a future bug rather than fixing observed data loss. Affected releases: v0.20.0 through v2.0.3,BrowserDbonly. The browser unit tests compile again on wasm32 (a duplicatepage_cache_capacityandtempfile-baseddir_synctests).
- Golden-file compatibility corpus (#391).
tests/golden/holds v7 files written by minigraf 2.0.3, each with a JSON manifest of its CRC32,tx_countand expected query results. They cover one checkpoint, several checkpoints, indexes rebuilt on open, a WAL left by a crash, a WAL whose entries were already checkpointed, and same-transaction multi-values (#371).tests/golden_corpus_test.rsopens a copy of each file and checks its manifest after open, after a checkpoint and reopen, and after one more write. Golden files are never regenerated, only added; a newgolden-corpusjob inpolicy.ymlfails a pull request that changes one. The generator is a standalone crate pinned tominigraf = "=2.0.3"(tests/golden/gen/v7/). - SIGKILL crash test checks committed data (#384).
tests/crash_kill_test.rsused to check only that the file reopened and one attribute query ran, so it missed #370. The child now runs a seeded workload (batches over several entities and attributes, batched retracts, re-assertions after a retract) and logs each transaction afterexecutereturns; it checkpoints after every transaction, every third, never (WAL replay), automatically every 2 writes, or every third withSyncMode::Normal. After aSIGKILLat a random point, the reopened file must hold exactly the model after the last logged transaction or the one in flight, through a full scan, attribute (AEVT), attribute+value (AVET) and entity-bound (EAVT) queries and:as-of; again after a second reopen and after a checkpoint plus reopen. On v2.x the workload avoids the known issues #371 (two values of one attribute in one transaction) and #435 (asserting a triple that is already live returns one row per assertion); the v3 version covers both. Per-PR CI runs 5 rounds; the nightly crash-kill workflow runs 100 per OS.MINIGRAF_CRASH_KILL_SEEDreplays a failure.
- Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one
WriteTransactionis lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435), andsave()is not crash-atomic (#374); all are fixed in v3.0.0. All v2.x known issues, with affected versions and workarounds, are listed in the pinned issue #421.
Patch release on the v2.x line with two data-integrity fixes, under the support policy. File format is unchanged (v7). No API changes. Upgrading is recommended for every v2.x user.
- Queries returned wrong results with more than 65,535 uncheckpointed facts (#445). Each fact not yet checkpointed was indexed by its position in memory, stored as a 16-bit number that stopped counting at 65,535. Every later fact was indexed as that same position, so entity-bound queries (
[:e :attr ?v]) and attribute scans ([?e :attr ?v]) silently lost or duplicated facts. This affected in-memory databases with more than 65,535 facts and file databases with more than 65,535 facts written since the last checkpoint. The stored data was always intact, and a checkpoint made file databases correct again. Positions are now 32-bit and checked. Affected releases: v0.7.0 through v2.0.2. The file format is unchanged. - Reopening next to an already-checkpointed WAL rewound the transaction counter (#447). The WAL normally disappears after a checkpoint. It survives one only if the process crashes between the checkpoint's commit and the WAL delete, if the delete fails (WAL-006), or with
wal_checkpoint_threshold(usize::MAX). When such a WAL held only entries already in the file, replay skipped them all and then reset the transaction counter from the facts it had loaded, which were none, so it reset to 0. The next transactions reusedtx_countvalues already in the file,:as-ofanswers mixed old and new transactions, and the next checkpoint made that history permanent. The counter now never moves backwards on replay. Affected releases: v0.5.0 through v2.0.2.
- Same-transaction multi-valued facts can read back as one value (#371, #287); the fix needs file format v8 and ships in v3.0.0.
save()is not crash-atomic (#374). All v2.x known issues, with affected versions and workarounds, are listed in the pinned issue #421.
Final planned release on the v2.x line. File format is unchanged (v7). No breaking API changes; one new user-facing error code (API-010). v2.x gets data-integrity and security fixes for 12 months after v3.0.0 ships (support policy).
-
Bound-entity point queries no longer pay for other attributes' history (#323).
[:e :attr ?v]now range-scans only(e, :attr)in the EAVT index instead of every record the entity has ever written. Reading a rarely changed attribute of a heavily rewritten entity no longer slows down as that entity's history grows: with 2,000 retract/reassert cycles on another attribute of the same entity, it drops from 4.58 ms to 19.0 µs. Reading the heavily rewritten attribute itself, and attribute scans ([?e :attr ?v]), are about 1.35–1.4× faster (4.64 ms → 3.36 ms), from cheaper net-assert grouping and removing a redundant dedup pass. The file format is unchanged. -
Attribute scans are bounded to exactly the queried attribute (#381).
[?e :attr ?v]computed the end of its AEVT range by incrementing the attribute's last byte. For attributes whose last byte is0x7For0xBF— including about 1 in 64 non-ASCII characters, such as:丿or:ÿ— the result was not valid UTF-8, so the scan ran to the end of the whole AEVT index and read every later fact before filtering. It also read facts for prefix siblings (:ab,:a/bwhen scanning:a). The range now ends atattribute + "\0", which covers exactly one attribute. Scanning 100:丿facts in a checkpointed file with 40,000 facts on neighbouring attributes drops from 45.4 ms to 251 µs. Results are unchanged. -
Checkpoints no longer re-encode the whole index (#315).
checkpoint()decoded and re-serialised every entry of all four covering indexes and re-read every page of the file for its checksum, so checkpointing after one new fact cost almost as much as after thousands. Index leaves that receive no new entries are now copied verbatim, only the leaves that do are decoded and re-split, and unchanged fact pages are no longer re-hashed. Leaves are still bounds-checked and the leaf chain is checked for cycles, so a damaged index fails the checkpoint instead of being copied forward. Checkpoint after one new fact drops from 246 ms to 26.3 ms on a 100k-fact file and from 23.9 ms to 4.0 ms at 10k. Cost still grows with graph size, because the index pages are copied on every checkpoint; the file format is unchanged. Thecheckpoint()andwal_checkpoint_thresholddocs now state that a checkpoint is not a durability boundary: the WAL is already crash-durable.
- A power loss could lose a new database or WAL file, or bring back a deleted WAL (#389). On Linux and other POSIX systems, a created or deleted file survives a power loss only once its parent directory is fsynced; Minigraf fsynced file contents but never a directory. Creating the
.graphfile or the<db>.walsidecar, and deleting the WAL after a checkpoint, now fsync the parent directory after the file operation. A resurrected WAL was already harmless, since replay skips entries at or below the last checkpointed transaction, but a lost.graphor WAL lost committed data. Windows needs no directory sync and is unchanged. Process kills were never affected, because they leave the OS page cache intact. - Recursive rules failed with a literal start when the recursion goes through another rule (#297).
(query [:find ?y :where (chain :a ?y)])over(chain ?x ?y) :- (link ?x ?mid) (chain ?mid ?y)failed withUnbound variable in rule head: ?mid(INT-022). The magic-sets rewrite, which runs when a rule argument is given as a literal, dropped earlier rule calls from the rules that pass bindings to the next call, so a variable bound only by such a call (here?mid, bound bylink) was never bound. #300 fixed the case where a fact pattern binds it. Those calls are now kept, and results match the same query without a literal start. or-joinfailed with INT-031 when no rows reached it (#405). A valid query such as[?item :order-item/product ?p] (or-join [?item] ...)returnedor-join variable ?item is not bound in the incoming scopeon an empty database, or whenever the clauses before theor-joinmatched nothing, but worked once matching data existed. A safety check read the bound variables from the incoming rows, so with no rows every join variable looked unbound.or-joinnow returns an empty result when no rows reach it, likeorandnot-joinalready did. This applies to queries and rule bodies.- A query with
$slotbind tokens run throughexecute()reported an internal error (#407). Such a query failed with[INT-025] internal: unsubstituted :valid-at bind slot reached the executorwhen it had a:valid-atslot, and with slots in other positions it could run and quietly return nothing.Minigraf::execute(),WriteTransaction::execute()and the REPL now reject it before execution with the new user error API-010, which names the slots and points toprepare(). Prepared queries are unchanged. - Bound-entity queries could drop rows when one
WriteTransactionwrote the same attribute in several valid-time windows (#323). Both facts share atx_count, and the selective lookup path de-duplicated on(entity, attribute, tx_count, asserted), so one window's value was silently lost, while the same query via a full scan returned both. That de-duplication has been removed, and bound-entity queries now always match a full scan. - The INT-054 negative-cycle error named its two predicates in a random order (#410). Registering
(rule [(p ?x) (not (q ?x))])and then(rule [(q ?x) (not (p ?x))])reportedpredicate 'q' is involved in a negative cycle through 'p'on some runs and the reverse on others, because stratification walked the rule dependency graph inHashMaporder. It now walks rules in sorted order, so the same rules always give the same message. Which rules are accepted or rejected is unchanged.
- Crash at every point inside
save()(#374, #390). A fault-injection test makes a checkpoint fail at each page write and each sync in turn, on a file already written by several checkpoints, then reopens what reached storage. At all 47 crash points the file reopens with either the old header and exactly the previously checkpointed facts (oldlast_checkpointed_tx_count, so WAL replay re-applies the in-flight transaction) or the new header and all facts, and every fact is found by both entity and attribute lookups. 45 of the 47 reopens take the index-rebuild path. This checks the v2.x behaviour described in the known-issues list (#421); it models a process kill, not a power loss.
- New
Policyworkflow (#395). An MSRV job runscargo check --all-featuresandcargo test --libon Rust 1.89, resolving dependencies to 1.89-compatible versions, and fails ifrust-versioninCargo.tomldrifts from the tested toolchain.cargo-semver-checkscompares the public API with the latest release tag and blocks PRs tomain; on major-version branches such asv3it reports breaks without failing.cargo-deny(deny.toml) allows only permissive licenses compatible with MIT OR Apache-2.0, rejects duplicate crate versions and wildcard requirements, and requires every crate to come from crates.io.
-
Unsafe WAL recovery advice removed from
docs/ERROR_REFERENCE.md. The WAL-001, WAL-002 and STG-011 resolutions said a.walfile could be deleted with no data loss, or that a database could be rebuilt from the WAL alone. Both are wrong: transactions committed since the last checkpoint exist only in the WAL, and the WAL holds nothing older than that checkpoint. The resolutions now say to copy both files first, to checkpoint with the version that wrote the WAL, and what is lost if the WAL is deleted. -
CLAUDE.md now matches the milestones (v2.0.2 final v2.x release, v3.0.0 format v8 and data integrity, v3.1.0 features) and documents
error.rs,magic_sets.rs,fault_inject.rsandsrc/browser/. ROADMAP lists #405 and #407 in the v2.0.2 scope. -
README: MSRV (1.89) stated, v2.x known issue (#371) shown near the top, Maven coordinates corrected to
io.github.project-minigraf, Android listed on Maven Central, and binding download locations point to the binding repos. -
.github/SECURITY.mdlists 2.x as the supported line. About 50 broken wiki links indocs/ERROR_REFERENCE.mdnow use full wiki URLs. -
Stability and support policy (#397). PHILOSOPHY.md §7 now states a format policy instead of "frozen for decades": what counts as a format change, that v(N+1) always reads v(N), and that older formats are dropped only in a major release. A new support policy gives v2.x data-integrity and security fixes for 12 months after v3.0.0 ships. "Production-ready", "12–15 months to production" and the check mark on "never losing data" are replaced with claims that hold today. SECURITY.md links the policy.
-
Support tiers (#400). The Rust crate and Python are Tier 1 (fully tested, released with every core release). Node.js, browser WASM, WASI, Java/JVM, Android, Swift and C are Tier 2 (experimental, best-effort releases). Tier 1 platforms are Linux ext4/xfs, macOS APFS and Windows NTFS on local disk. The README platform table shows each binding's tier.
-
Known-issues process (#399). Issues labelled
data-integrity,corruptionordurabilitystay open until their fix is in a published release (CONTRIBUTING.md). A newknown-issuelabel and a pinned issue (#421) list each bug in the current release with affected versions, workaround and fix version; the README links it from a new "Known issues" section. #287 was reopened under this rule. The bug report template asks for the affected version range, binding and filesystem.
- Same-transaction multi-valued facts can read back as one value (#371, #287); the fix needs file format v8 and ships in v3.0.0. Workaround: write or retract each value of a multi-valued attribute in its own call.
save()is not crash-atomic (#374), and indexes damaged by the pre-v2.0.1 rebuild bug are not repaired (#373). All v2.x known issues, with affected versions and workarounds, are listed in the pinned issue #421.
- v3.0.0 drops read support for file formats v1–v6. v3.0.0 reads format v7 and migrates it to v8. Every v1.x and v2.x release migrates v1–v6 files to v7 on open, so open any older file once with v2.x before upgrading to v3.0.0.
- On v2.x, reading a heavily rewritten attribute still costs time proportional to its history, because v7 index keys carry neither the value nor the assert/retract flag; the structural fix needs the v8 keys and is tracked for v3.0.0 in #379.
- Checkpoints on v2.x still copy every index page, so their cost grows with graph size. Copy-on-write index pages on the v3 branch, which also make
save()crash-atomic, are needed for checkpoints proportional to the change alone (#374).
Patch release on the v2.x line. File format is unchanged (v7); no API changes.
- Entity-bound lookups could silently return nothing after an index rebuild on open (#370). When the index checksum does not match on open (for example after a process was killed during
save()), Minigraf rebuilds all four indexes from the fact pages. The rebuild computed each fact's location by re-packing all facts contiguously, but a file written by several checkpoints has partially filled pages that a contiguous re-pack does not reproduce. From the second checkpoint onward, rebuilt index entries pointed at the wrong page/slot: entity-bound queries ([:some/ident ?a ?v]) returned[]or errored while attribute-driven queries still worked, andcheckpoint()copied the bad entries forward. The rebuild now reads each fact's real on-disk location (#372).
- Files whose indexes were already rebuilt with wrong locations by v2.0.0 are not repaired by this release. A public integrity check and rebuild-indexes-from-fact-pages operation is tracked in #373; a crash-atomic
save()in #374. - Two values of the same attribute for one entity written in a single
transact(or retracted in a singleretract) can read back as one value, differently per query path (#371, #287). The fix changes the index key layout (file format v8) and ships in v3.0.0. Workaround on v2.x: write or retract each value of a multi-valued attribute in its own call.
This was originally slated as v1.3.0, matching the GitHub milestone name under
which most of this work was tracked. It ships as v2.0.0 instead: this
project's CHANGELOG header (above) commits to Semantic Versioning, and under
strict SemVer any backward-incompatible change to the public API past 1.0.0
requires a major version bump, not a minor one. The breaking changes below —
a public method return-type change, two OpenOptions struct-literal breaks
plus #[non_exhaustive] closing that gap for good, Windows lock-semantics
changes, and an MSRV bump — are exactly that. GitHub milestones v1.4.0,
v1.5.0, v1.6.0, and the pre-existing untitled "2.0" (format v8 + window
completeness + FFI expansion) were renumbered to v2.1.0, v2.2.0, v2.3.0, and
v3.0.0 respectively to keep the sequence consistent.
-
Adding
OpenOptions::allow_unlockedbroke struct-literal construction. At the time this field landed,OpenOptionshad all-public fields and was not#[non_exhaustive], so under Rust's semver rules any downstream code building it asOpenOptions { wal_checkpoint_threshold: .., page_cache_size: .., .. }without..Default::default()no longer compiled. Callers who spread..Default::default(), or who use the chainable builder methods, were unaffected. This branch had to update two such literals in its own tree — theDefaultimpl itself and one test — and only the latter is a "downstream literal" in the sense that matters to a consumer.This was the first post-1.0 field addition to
OpenOptions. The earliermax_derived_factsandmax_resultsfields landed in v0.19.0, before 1.0, so they set no precedent under the stability guarantee in PHILOSOPHY.md §7. Combined with thesynchronousfield below, it's whyOpenOptionsis now#[non_exhaustive]— see that entry. -
OpenOptionsgains a second post-1.0 field:synchronous(#302). Same semver consequence asallow_unlockedabove — a struct-literalOpenOptions { .. }built without..Default::default()no longer compiles. One in-tree literal (src/db.rs, a test) needed updating; every other in-tree construction already used the spread form or the chainable builder. -
OpenOptionsis now#[non_exhaustive]. This is a stronger break than the two field additions above: it disallows any struct-literal construction from outside this crate, including the..Default::default()spread form that made those additions non-breaking for spread-form callers. Construct viaOpenOptions::new()/OpenOptions::default()plus the chainable builder methods instead. Landed alongside theallow_unlocked/synchronousfields specifically so this is the last time a newOpenOptionsfield forces a major-version bump. A newwal_checkpoint_threshold(n)builder method was added in the same change — the field had none before, which is why every in-tree construction site (tests, benches, one example) used a struct literal for it and had to be converted to the builder form. -
Minimum supported Rust version is now 1.89 (was effectively 1.85, implied by
edition = "2024"). Required forstd::fs::File::try_lock, stabilised in 1.89. Declared asrust-versioninCargo.toml, so cargo reports a clear error rather than a confusing one. -
On Windows, an open database can no longer be read through any other file handle — including another handle in the same process.
LockFileExlocks are mandatory rather than advisory and exclude every handle but the one holding them, so copying or backing up an open.graphfile now fails on Windows where it previously succeeded, and so does opening it yourself for a raw read while the database is live. The error is os error 33, "another process has locked a portion of the file", even when the other process is you. Unix locks remain advisory and are unaffected. Close the database before reading the file directly — which also avoids a torn read. -
open()now fails on filesystems that cannot lock at all rather than proceeding unprotected. SetOpenOptions::allow_unlocked(true)to accept the risk on single-writer deployments. -
This detection does not cover NFSv3 exports mounted with
-o nolock(#334). Linux's NFS client servesflock/OFD locks for anolockmount out of its own local, in-kernel lock table instead of contacting the server, sotry_lockreports an ordinary local success and looks identical to a mount where locking genuinely works —allow_unlockedis never consulted because the code never learns the mount can't really lock. Two separate client hosts writing the samenolockexport can each believe they hold the lock with no cross-host coordination. There is no way to detect this from the lock call's result, sonolockNFSv3 exports are an unsupported deployment for multi-writer use — the same category as running mixed Minigraf versions against one file, below. Seedocs/ERROR_REFERENCE.mdSTG-027. -
A database genuinely locked by another process now reports its error later than before — about 375ms later.
FileBackend::open_withretries a cross-processWouldBlockup to 10 times with backoff doubling from 5ms and capped at 50ms before concluding another process holds the lock. This exists becauseforktransiently duplicates the lock-holding descriptor into a child untilexecvecloses its close-on-exec descriptors, so any process that spawns subprocesses — the normal pattern for the Python and Node bindings — could see a spuriousWouldBlockfrom its own child. SQLite solves the same problem withbusy_timeout; this is the same idea, bounded so a real conflict still fails, just not instantly. A same-process conflict (this process already holds the file open) is unaffected: it fails immediately, since waiting could never help. -
A refused open on a filesystem that cannot lock may leave a 0-byte
.graphfile behind. The file must exist before it can be locked, so the create-then-lock ordering now creates before it can know the lock will be refused. Harmless — the next open seesis_newand initialises normally — but worth knowing if you inspect the filesystem after a failed open. -
Minigraf's,WriteTransaction's, andPreparedQuery's public methods now returnResult<T, MinigrafError>instead ofanyhow::Result<T>(#277).MinigrafErrorimplementsstd::error::Error(so?still works againstanyhow::Result/Box<dyn Error>callers) and adds.category() -> ErrorCategoryand.code() -> &'static strfor structured error matching. This lands the foundation for issue #277; every error currently surfaces as the genericINT-000code until each error category's follow-up PR wires up its realdocs/ERROR_REFERENCE.mdcodes. Every error'sDisplay/to_string()output now also carries a[CODE]prefix (e.g.[INT-000] unclassified internal error: ...) that it did not have before — a behavior change beyond the type signature. Anything that string-matches error output is affected, including the interactive REPL's printed error messages and all 7 language-binding repos (Python, Node, WASM, Java, Android, Swift, C). -
src/query/datalog/parser.rsnow carries realPRS-0xxcodes instead of falling back toINT-000(#358, #277 2/6). All 79 documentedPRS-0xxcodes are wired up viabail_coded!/err_coded!at every parser call site that maps onto one; a parse error's.code()is now e.g."PRS-005"("Unclosed list") instead of the generic"INT-000", and itsDisplay/to_string()prefix changes to match.parser.rs's internal signatures changed fromResult<_, String>toanyhow::Result<_>(crate-internal only — the publicdb.execute()/db.prepare()surface is unchanged, stillResult<T, MinigrafError>). Two documented codes, PRS-028 ("window expression cannot be empty") and PRS-049 ("unexpected end of fact vector"), are wired up but unreachable through the public API — thehas_over/len() >= 4conditions that guard their call sites already guarantee the "bad" case can't occur, so no input reaches them. This is a pre-existing property of the parser logic, not something this PR changed. Two static message strings changed wording to match theirdocs/ERROR_REFERENCE.md"Error text" verbatim (previously drifted):"Retract argument must be a vector"→"...must be a vector of facts"(PRS-044). Anything that string-matches parser error output — including the interactive REPL and all 7 language-binding repos — is affected by both the code prefix and this wording change. -
Storage/file-format errors now surface their real
STG-0xxcode (#359, part of #277's rollout): all 27 documentedSTG-0xxcodes are wired up acrosssrc/storage/{mod,persistent_facts,btree,btree_v6,packed_pages}.rsandsrc/storage/backend/file.rs— header validation (STG-001–STG-009), header re-read failures (STG-010), on-disk B+tree/packed-page corruption (STG-011–STG-015), a poisoned backend mutex (STG-016), page-count arithmetic overflow guards (STG-017–STG-024), and the three lock conflict errors (STG-025–STG-027).docs/ERROR_REFERENCE.md's STG section's "Error text" lines were rewritten from one-off example values to canonical{}-placeholder templates so they byte-for-byte match theREGISTRYentries the sync test checks against.FileBackend::open_withno longer collapses a more specific header-parse failure (e.g. a bad magic number) into the genericSTG-010— it now propagates aCodedErroralready present in the chain as-is, only falling back toSTG-010for a genuinely uncoded (e.g. raw I/O) failure. Not covered by a dedicated regression test:STG-011's call site is guarded by adebug_assert!that always fires first in a debug/test build, so it's release-build-only and unreachable undercargo test; the 8 arithmetic-overflow guards (STG-017–STG-024) andSTG-027(a filesystem that refuses locking outright — no CI runner provides one) are covered by the registry↔doc sync test and code review but have no dedicated trigger test, consistent with their own "should not occur under normal operation" documentation. Twobail!sites with no matching documentedSTGcode ("Header checksum mismatch", reached on a genuine on-disk corruption of an otherwise-valid header, andinto_backend's multiple-owners precondition) were deliberately left uncoded (INT-000) rather than assigned an undocumented code — left for the final API+INT audit PR. -
src/wal.rs's errors now carry realWAL-0xxcodes instead of falling back toINT-000(#360, toward #277): a bad.walmagic number isWAL-001, an unsupported WAL version isWAL-002, a fact whose serialised size exceeds the WAL's per-entry limit (~4080 bytes) isWAL-003, a fact whose serialised size exceedsu32::MAXisWAL-004(practically unreachable — WAL-003's limit fires first), a WAL header'snum_factsexceeding the platformusizeisWAL-005(unreachable on any 64-bit target), and a failure to delete the sidecar.walfile after a successful checkpoint isWAL-006.docs/ERROR_REFERENCE.md's WAL-002/003/004/006 "Error text" entries were rewritten from a concrete example value to the canonical{}-placeholder template form so the registry↔doc sync test can compare them byte-for-byte; the concrete example now lives in each entry's prose instead. -
Query executor errors now carry real
QRY-00Ncodes (#361, part of #277) instead of the genericINT-000fallback:QRY-001invalid entity,QRY-002attribute must be a keyword,QRY-003cannot transact a pseudo-attribute,QRY-004invalid value,QRY-005transaction failed,QRY-006retraction failed,QRY-007unknown predicate,QRY-008functions lock poisoned,QRY-009rules lock poisoned — the full QRY category (9 of 9 documented codes; seedocs/ERROR_REFERENCE.md). Anything that was matching onMinigrafError::code() == "INT-000"for one of these conditions now sees the specific code instead.QRY-001/004/005/006'sERROR_REFERENCE.md"Error text" entries were also rewritten from a concrete example (e.g.`Invalid entity: "not-a-uuid"`) to the canonical{}-placeholder template form (`Invalid entity: {}`) they share withREGISTRY, per the design spec's migration convention; the worked example moved to each entry's "Example" section, unchanged.QRY-001..006(the transact/retract validation codes) are wired intoDatalogExecutor::execute_transact/execute_retract, but that code path is not reachable throughMinigraf::execute()today — the public write path routestransact/retractthroughMinigraf::materialize_transaction/materialize_retraction(src/db.rs) instead, which raise their own uncoded (stillINT-000) errors, two of which — "attribute must be a keyword" and "cannot transact a pseudo-attribute" — are already earmarked asAPI-003/API-004for the API+INT category PR (#277 step 6). -
The core binary size budget is raised from 1MB to ~1.2MB (
binary-size.yml'sSIZE_LIMIT_BYTES, and PHILOSOPHY.md §4's stated target), toward #277. The project was already at the 1MB wire before #277 started (measured ~1034 KiB pre-rollout on a controlled local build); #360 (WAL, 6 codes) and #361 (QRY, 9 codes) together added only ~9 KiB, and even that was enough to fail CI. All existing size levers —opt-level = "z",lto = true,codegen-units = 1,strip = "symbols", and the non-genericbail_coded!/err_coded!/format_templatedesign (no per-call-site monomorphization, unlike the genericbuild_btreefixed for the same reason in v0.x) — were already in place;cargo bloatshows no single dominant symbol to cut, just diffuse growth across ~2000 small functions. Extrapolating to the full 130-code rollout (PRS: 79, STG: 27, WAL: 6, QRY: 9, API+INT: 9) puts the total growth at roughly 60-70 KiB over the pre-#277 baseline, which no further micro-optimization of already-maxed settings can absorb. ~1.2MB keeps meaningful headroom above that projection without abandoning the philosophy's small-binary principle. -
Every remaining
bail!/anyhow!call site in the crate now carries a structured code — nothing falls through toINT-000by default anymore (#362, #277 step 6/6, the final sub-PR of the umbrella issue). Two parts:Part 1 —
src/db.rs's API-layer call sites now carry their realAPI-0xxcodes:API-001write lock poisoned (execute/begin_write/checkpoint),API-002unexpected command variant in the write path (structurally unreachable — kept for defensive completeness, no test possible),API-003/API-004attribute-must-be-keyword / cannot-transact-a-pseudo-attribute at thematerialize_transaction/materialize_retractionlayer (mirroringQRY-002/QRY-003),API-005/API-006/API-007db.prepare()rejectingtransact/retract/rule,API-008function registry lock poisoned (register_aggregate/register_predicate),API-009WAL not initialized (also structurally unreachable —wal_write_stamped_batchalways populateswaltwo lines above theok_or_else). Regression tests assert the exact code for every reachable one, including two new lock-poisoning tests that deliberately panic while holdingwrite_lock/functions(viacatch_unwind) to exerciseAPI-001/API-008for real rather than by inspection.db.rs's own "invalid entity"/"invalid value" checks inmaterialize_transaction/materialize_retractionhave noAPI-0xxcounterpart indocs/ERROR_REFERENCE.md(onlyQRY-001/QRY-004do, and those cover a different, unreachable-from-db.rscall path — see the QRY entry above) and aWriteTransaction-already-active reentrancy check has no documented code at all, so all three became newINT-0xxcodes (INT-001,INT-002,INT-003) — part of the audit below.Part 2 — every other uncoded
bail!/anyhow!site crate-wide (~250 of them, across 18 files:temporal.rs,parser.rs's long-tail internal-error/syntax branches the PRS migration left uncoded,evaluator.rs,executor.rs's remaining sites,prepared.rs,functions.rs,rules.rs,stratification.rs,graph/storage.rs,storage/mod.rs,storage/persistent_facts.rs,storage/packed_pages.rs,storage/btree.rs,storage/btree_v6.rs,storage/backend/{file,memory}.rs,storage/cache.rs,browser/buffer.rs) — assigned one of 52 newINT-0xxcodes (INT-004–INT-055;docs/ERROR_REFERENCE.md's old "Appendix: Internal Errors" bullet list is gone, replaced byINT-006's formal entry, which covers the same 8 "internal parser error: expected X token" strings via a{}placeholder). Mechanically-repeated call sites share one parameterised code rather than getting one each — mirroring the patternQRY-005/STG-016/WAL-006already established for "wrap an arbitrary underlying cause" and "one poisoned-lock code per resource": e.g.INT-011covers all ten "unexpected end of<clause>" parser messages,INT-018covers all four "variable not bound by any outer/earlier clause" messages,INT-050covers all thirteen lock-poisoned sites across four files ({}names the specific lock), andINT-048/INT-049are broad "storage: arithmetic overflow"/"storage: internal invariant violation" buckets covering dozens of B+tree/packed-page bounds-check and overflow-guard sites that were never meaningfully distinct from one another. A handful of genuinely user-reachable conditions that the original five category PRs simply never got to — the recursion/ derived-facts/result-limit family inevaluator.rs(INT-020) and the runtime aggregate type-mismatch family infunctions.rs(INT-041–INT-044) among them — areINT-0xxrather thanQRY-0xxpurely because this PR's scope is "assign every remaining site anINTcode," not "reopen the closed PRS/QRY/STG/WAL category PRs to extend their code ranges"; two existing regression tests intests/error_codes_qry_test.rsthat had locked inINT-000for exactly these two families before this PR (recursive_rule_derived_facts_limit_returns_int000,aggregate_type_mismatch_returns_int000) were updated in place to assert their new codes instead, and two similarly-named unit tests insrc/query/datalog/evaluator.rswere fixed the same way. -
The
REGISTRY↔docs/ERROR_REFERENCE.mdsync test is now a full bidirectional equality check (#362, #277 step 6/6), replacing the subset-only check the foundation PR (#357) introduced. Renamedregistry_is_a_subset_of_error_reference_doc→registry_matches_error_reference_doc_bidirectionally: it now also asserts every documented### CODEsection with an "Error text" line has a matchingREGISTRYrow, not just the reverse. This is only safe now that Part 2 above means no documented code can be missing a registry entry because its call site hasn't been migrated yet — the last such gap is closed. The final tally: 122 pre-#362ErrorCodevariants (PRS×79,QRY×9,STG×27,WAL×6,INT-000×1) plus 9 newAPI-0xxand 55 newINT-0xx(INT-001–INT-055) = 186 total.
-
A
.graphfile is no longer bricked when its holder dies in a container (#317):FileLockrecorded the holder's PID in a.graph.locksidecar and refused to open when that PID equalled our own. Every container's main process is PID 1 in its own namespace, so a container killed with SIGKILL left the sidecar holding1, and the replacement container — also PID 1 — read its own PID back and refused. The refusal was permanent; no code path could clear it, and an operator had to delete the file by hand. Thepid == our_pidrefusal was itself the fix for #304, so the two defects were entangled.A PID cannot serve as a liveness token: it is not unique across PID namespaces and is recycled within one. The sidecar is gone, and the lock now lives in the kernel via
std::fs::File::try_lockon the.graphfile itself. The kernel releases it whenever the process exits, however it exits, so a crashed holder leaves nothing behind. Becauseflockand OFD locks attach to the open file description rather than the process, a second open within one process is also denied, so #304 stays fixed with no PID bookkeeping at all.A leftover
.graph.lockfrom a version before 1.3 is ignored and never deleted — it no longer has any effect, and a still-running old process may still depend on it. Running mixed versions against one file is not supported; upgrade all writers together.Reported and diagnosed by @ocasazza in #317, who supplied the reproduction, identified the PID-namespace mechanism, and correctly traced the refusal back to the #304 fix that caused it. That analysis is what made this straightforward to verify. One of the tests from their proposed patch is the ancestor of the regression test that now covers this.
-
Keywords may contain
?::person/alive?now lexes as a single keyword. Previously?terminated the keyword, so[:e :alive? true]lexed as:alivefollowed by a stray?symbol and was rejected as a four-element fact — reportingOptional 4th element of a fact must be a map, which named the value rather than the keyword.?is a constituent character in EDN keywords and predicate-style names (:artist/dead?) are idiomatic. Query variables are unaffected: a?not preceded by:still begins a symbol.tests/grammar/grammar.pestupdated to match. -
The parser no longer silently discards trailing input after a complete form (#305).
parse_ednreturned successfully as soon as it parsed one complete top-level value, without checking whether any tokens remained — so[1 2 3] garbage ) ] } 12345parsed as[1 2 3]with everything after it dropped, no error, no indication anything was ignored.parse_ednnow errors if tokens remain once the top-level value is parsed. Separately,(query [...])and(rule [...])accepted and silently ignored any arguments after their vector, the same defect(transact ...)/(retract ...)already closed for #303 — so(query [:find ...] :max-results 5)parsed as the query alone, discarding:max-results 5(the correct place for that clause is inside the vector:[:find ... :max-results 5]) rather than rejecting the malformed call. Both now reject extra trailing arguments with the same "unexpected trailing argument(s)" wordingtransact/retractalready use.
-
BrowserDbno longer allocates a redundant LRU page cache (#275):open_in_memory(),open(), andimport_graph()now pass page cache capacity0toPersistentFactStorage::newinstead of256. The backingBrowserBufferBackendis already aHashMap-resident, fully-loaded page store, so everyPageCachehit was a second RAM-to-RAM copy plus LRU bookkeeping for no benefit.This required fixing
PageCacheitself: capacity0previously meant unbounded (the eviction loop'scapacity > 0guard skipped eviction but insertion still ran unconditionally), which would have made this change strictly worse — an ever-growing duplicate of every page ever read, never reclaimed.PageCache::new(0)now means the cache is disabled outright:get_or_loadalways reads through to the backend and < 10BC8 code>put_dirty is a no-op. No existing caller passed0before this change, so no other behaviour is affected. -
not/not-joinclauses are pushed to the earliest position where their variables are bound (#248):optimizer::plan()now acceptsNot/NotJoinalongsidePattern/Exprand interleaves each one right after the pattern that completes its required variables, instead of always applying it as a global post-filter after every pattern (and afteror/or-join). This shrinks the intermediate binding set flowing into any patterns that follow it in the plan. A clause whose variables are only ever bound by anor/or-join(not by any pattern in the same clause list) is deferred to the post-filter exactly as before —or/or-joinisn't visible to the planner, so pushing it early would run the clause before its dependency exists. Benchmark:negation/not_pushdown_selectivity(10k facts, three joins after thenot) shows the effect scaling with the excluded fraction, from ~2% at 0% excluded up to ~55% faster at 100% excluded. -
or/or-joinbranches can now short-circuit (#250): branches are still sorted cheapest-first (landed earlier), but once the branches evaluated so far already produce a match for every incoming binding, remaining (costlier) branches are skipped instead of always being evaluated. This is always safe foror-join— branches are projected down to already-bound outer variables before merging, so a skipped branch could only have produced a row identical to one already kept. For plainor, it's only safe when the branches evaluated so far introduce no variable beyond what's already bound in the incoming scope (a pure filter) — a branch that binds a genuinely new variable can legitimately contribute a distinct row for an already-"covered" key, so that case still evaluates every branch, exactly as before. Benchmark:disjunction/or_short_circuitanddisjunction/or_join_short_circuit(10k facts, both branches fully covering) show ~11% and ~27% improvement respectively. -
WAL writes no longer pay a full
fsync(), and can skip the per-write flush entirely (#302):WalWriter::append_entrycalledFile::sync_all()(fsync) after every write, flushing inode metadata the WAL — a pure-append file — never needed alongside the data. It now callsFile::sync_data()(fdatasync) instead: same durability guarantee, one fewer metadata flush, for every existing caller, no other change required. A newOpenOptions::synchronousfield (SyncMode::Full, the default, matches this fdatasync behavior;SyncMode::Normalskips the per-write flush altogether) lets bulk loaders/migrations that can safely re-run from a checkpoint watermark trade per-write durability for throughput —checkpoint()still fsyncs the main file unconditionally in both modes, so it remains the hard durability boundary regardless ofsynchronous. Separately:begin_write()+ Nexecute()+ onecommit()already collapsed N facts into a single WAL write / single fsync before this change; that existing batching pattern is now documented (README, wiki Performance Tuning page) since it was easy to miss.
No changes to the core crate. Released so the language bindings have a version to republish on, carrying a fix that lives in a binding rather than here.
v1.2.2 made a second handle on an already-open file an error (#304). The
UniFFI bindings and the C API already had a way to release a handle on demand
(destroy() and minigraf_close respectively), but the Node binding did not,
and JavaScript has no deterministic destructor — so reopening a .graph file
in one process became impossible there. minigraf-node gained a close()
method (project-minigraf/minigraf-node#1); this release is what lets it ship.
Binding versions track the core version, so all seven bindings republish at 1.2.3.
Drop-in replacement for v1.2.1. No file-format changes, no public API changes.
-
Two handles on one file in the same process (#304):
FileLock::acquiretreated a lock file whose holder PID equalled our own as stale, deleted it, and opened anyway — on the theory that it could only be a leaked handle. The far more likely cause is a handle that is open and in use right now elsewhere in the process, so the "self-heal" silently produced two liveFileBackends on one file. Each caches its ownheader.page_count, allocates new pages from that count, and bounds-checksread_pageagainst it, so the two page tables diverge. That — not an allocation off-by-one — is the source of the intermittentPage N out of bounds (total pages: M). It also corrupted the lock itself: whichever handle dropped first ranFileLock::dropand removed the lock file that by then belonged to the survivor, leaving a live handle unlocked and admitting other processes too. A lock held by our own PID is now refused with an error naming the same-process case; a lock held by a different, dead process is still reclaimed as before. Behaviour change: callers that relied on reopening a path they already hold open now get an error instead of silent corruption, and should reuse the existing handle (Minigrafis cheap to clone and all clones share one database). -
String comparison in predicates:
[(< ?a ?b)],>,<=and>=now order two strings lexicographically instead of failing. Previously every ordering predicate routed throughto_float_pair, which errors on non-numeric operands, so a string comparison silently matched no rows — and because both directions returned nothing, a range filter dropped the entire relation rather than partitioning it. This most often bit ISO-8601 timestamps stored as strings. The predicate path now agrees withvalue_lt/value_cmp, whichmin,maxand:order-byalready use. Mixed-type comparison (string vs number) remains an error, and NaN comparisons remain false.
Drop-in replacement for v1.2.0. No file-format changes, no public API changes.
- Magic sets
fbadornment: queries with a literal keyword in value position (e.g.(reports-to ?emp :alice)) now return correct results instead of empty results (#298). Root cause: seed fact used an ephemeral random UUID as entity; the 1-arg guard bound the variable to that UUID rather than the keyword value. Fixed with a deterministic sentinel entity (Uuid::new_v5) and a 2-arg guard that binds from value position. - Magic sets recursive rules:
(reach :a ?y)with intermediate variable?midand keyword entity binding now evaluates correctly (#297).
Drop-in replacement for v1.1.x. No file-format changes, no public API breaking changes. Upgrading requires no code changes.
- Magic Sets rewriting — recursive Datalog queries with bound arguments are now automatically rewritten top-down via magic sets, propagating bound values into recursive rules to avoid full-relation scans (#289)
- Adornment classification, seed fact generation, magic guard injection, SCC-aware propagation rule generation, full
rewrite()wired intoexecute_query_with_rules - Limitation: mutual recursion through negation is not rewritten (documented in ROADMAP §9.6)
- Adornment classification, seed fact generation, magic guard injection, SCC-aware propagation rule generation, full
- Per-query complexity limits —
:max-derived-factsand:max-resultsclauses added toquery; global default raised to 1,000,000 derived facts (#288, #290)
selective_fact_fetch: includeassertedflag in dedup key — previously retracted facts could shadow live facts under certain access patterns (#285, #286)selective_fact_fetch: restore per-pattern entity priority lost in a prior refactor (#283)- v5→v6 migration: fix hang caused by using
header.page_countas the B-tree start page instead of the correct offset (#272)
- Eliminate backend mutex hold on cache hits in
CommittedFactLoaderImpl::resolve— read path no longer acquires the write lock when the page is cached (#279) - Pre-build
MutexStorageBackendinCommittedFactLoaderImplto eliminate oneArc::cloneperresolve()call (#280, #281)
- Split Java, Android, Swift, and C bindings into independent repos under
project-minigraforg — completes the #231 follow-up work.minigraf-java: https://github.com/project-minigraf/minigraf-javaminigraf-android: https://github.com/project-minigraf/minigraf-androidminigraf-swift: https://github.com/project-minigraf/minigraf-swiftminigraf-c: https://github.com/project-minigraf/minigraf-c
minigraf-ffiyanked from crates.io — UniFFI layer inlined into each binding repo's private shim; use the per-language packages insteadcascade.ymlupdated to dispatchcore-releaseto all 7 binding repos on every version tag- All binding repos migrated to OIDC trusted publishing —
NPM_TOKENandCARGO_REGISTRY_TOKENsecrets removed; crates.io usesrust-lang/crates-io-auth-action@v1, npm usessetup-node@v6OIDC - Python, Node, and WASM binding repos now include a
preparejob that pins theminigrafdependency version inCargo.tomland commits before building — consistent with Java, Android, Swift, and C
- Add
docs/ERROR_REFERENCE.md: full inventory of user-facing errors (PRS/QRY/STG/WAL/API categories, 113 entries) with cause, resolution steps, and bad-input examples; docs-only reference codes PRS-001…API-009 (#192)
Patch release. Fixes cargo-dist Windows build failure that prevented REPL binaries and crates.io publish from completing for v1.1.0. No code changes to the library itself.
- Exclude
fuzzcrate from workspace so cargo-dist can build on Windows (MSVC linker incompatible with#![no_main]libFuzzer targets) — fixes #263
Drop-in replacement for v1.0.0. No file-format changes, no public API changes, no query surface changes. Upgrading requires no code changes.
- Hash-join replaces nested-loop join for multi-clause queries — O(N) instead of O(N²) for large fact sets (#202, #203, #204)
- Selective B+Tree fact fetch: queries with bound entity/attribute skip full-scan and read only relevant index pages (#208)
- Predicate push-down into join ordering (#207)
- Cost-based clause ordering for
not/orrules (#206, #205) - SIMD crossover analysis and benchmarking infrastructure (#229)
- Fixed critical correctness and durability bugs found during deep audit (#225): fact visibility edge cases, WAL entry ordering, checkpoint atomicity
- Fixed read-only handle
Droptriggering unnecessary checkpoint, modifying the file on close (#226) QueryResult::TransactedandRetractedreverted to tuple variants — struct-variant form introduced post-1.0 was a breaking change (#261)- Added
Minigraf::current_tx_count() -> u64as additive API to expose the:as-ofmonotonic counter without breaking existing pattern matches
- WAL fault injection harness (
FaultInjectingBackend) with 9 crash-recovery tests (#209, #210, #214) - Storage/migration resilience: migration matrix, index corruption recovery, concurrency stress tests (#215, #216, #217)
- Property-based query correctness tests against a reference evaluator (proptest, #212)
- Datalog parser/evaluator fuzz targets with seed corpus (#213)
- Per-module branch coverage gates in CI (#219)
- Long-haul smoke suite: 500 entities × 10 write/read/checkpoint cycles (#220)
- XTDB and Datomic semantic compatibility tests (#221)
- Workspace-wide clippy lint enforcement — 336 violations fixed (#232)
- Grammar conformance test harness: pest shadow grammar + EDN corpus (#233)
- CI: codecov-action v3→v5, actions-rs→dtolnay migration
Six PRs hardening the v1.0.0 codebase: WAL fault injection, storage/migration resilience, query correctness (property-based + coverage gates), long-haul smoke testing, and XTDB/Datomic semantic compatibility. No API changes, no file-format changes, no new runtime dependencies.
PR #254 — #209 + #210 + #214: WAL Fault Injection
FaultInjectingBackend(#[cfg(test)]-only wrapper aroundStorageBackend) — configurable write-fail, flush-fail, read-fault injection- 9 new WAL fault tests: write-fail propagation, flush-fail without data corruption, read-fault on WAL replay, CRC corruption discard, checkpoint atomicity under backend failure, partial checkpoint recovery via WAL replay, multi-writer serialisation, concurrent write+checkpoint, backend error propagation as
Errnot panic
PR #257 — #215 + #216 + #217: Storage & Migration Resilience
tests/migration_matrix_test.rs— 5 migration tests: v7 round-trip, v3 empty migrate, corrupt magic returnsErr, unsupported version returnsErr, WAL replay idempotenttests/index_corruption_test.rs— 5 corruption-resilience tests: checksum mismatch triggers index rebuild, btree leaf/internal corrupt pages returnErrwithout panic, root pointer mismatch handled, non-critical corruption still serves queries on good data- 5 new concurrency stress tests in
tests/concurrency_test.rs: stress readers during writer, failed write then success, rollback after partial work, open/write/checkpoint/query loop per thread, nightly stress loop (#[ignore])
PR #256 — #212 + #213 + #219: Property-Based Testing & Coverage Gates
tests/property_test.rs(proptest,cfg(not(wasm32))): 3 property tests — EAV model correctness vs naive reference evaluator, bi-temporal monotonicity, retract visibility invariant.github/workflows/coverage-gates.yml— per-module branch coverage thresholds; CI fails if coverage drops below gate
PR #258 — #220: Long-Haul Smoke Suite
tests/smoke_test.rs(#[ignore]nightly):smoke_large_graph_10_cycles— 500 entities × 10 attributes × 10 update cycles; 7 invariants: active count (333), retracted count, fact count bounds, temporal snapshot integrity, prepared query consistency, recursive rule transitive closure, WAL checkpoint round-trip.github/workflows/smoke.yml— nightly 5am UTC, 15-min timeout, runs--include-ignored
PR #259 — #221: XTDB & Datomic Compatibility Corpus
tests/xtdb_compat_test.rs— 10 semantic ports (Apache 2.0): EAV, tx-time:as-of, valid-time:valid-at, retraction (current + historical), Datalog join, negation, recursive rules, parameterised queries, combined bi-temporaltests/datomic_compat_test.rs— 9 independently written semantic ports: datom model, multi-entity attribute, tx-time:as-of, retract-entity, multi-variable:find, ground-value binding, parameterised query (prepared), named reusable rules, predicate expression filter
935 tests passing (943 total, 8 ignored: 6 or+neg-cycle stratification doc tests, 1 nightly concurrency stress, 1 nightly smoke).
Three optimisation PRs extending the v1.0.0 performance work. No API changes, no file-format changes, no new runtime dependencies. Test count unchanged at 850.
PR #249 — #207 + #206: Predicate Push-Down & Mixed Rule Optimization
optimizer::plan()extended to acceptExpr(predicate/arithmetic) clauses; they are interleaved at the earliest position where all their variables are bound, minimising intermediate binding setsStratifiedEvaluatorgains mixed-rule path: when a rule stratum contains both positive-only rules and rules withnot/not-join, the evaluator now evaluates all positive rules first and applies negation filters in a second pass within the same stratum — eliminates a class of ordering-dependent bugs in cross-stratum negation
PR #251 — #205: Cost-Based not/or Ordering
optimizer::plan()assigns selectivity estimates tonot/not-joinandor/or-joinclauses; they are sorted after their ground-variable producers but before unconstrained pattern scansnot/not-joinblocks placed after the patterns that produce their join variables (was: end of plan regardless);or/or-joinblocks placed by estimated output cardinality
PR #253 — #229: SIMD Benchmarking & Crossover Analysis
benches/simd_helpers.rs:valid_time_filter_simd,as_of_filter_simd,sum_simd_i64— portable SIMD kernels usingwide::i64x4/u64x4- Criterion benchmark groups:
simd_temporal,simd_as_of,simd_aggregate— scalar vs. SIMD crossover analysis at 1K–1M facts - Analysis result: SIMD crossover occurs at ~8K–16K facts per query; scalar path preferred below that threshold; SIMD integration deferred to post-1.0 backlog pending real-workload profiling
850 tests passing (844 passing + 6 ignored: confirmed or+neg-cycle stratification bug, deferred to post-1.0 backlog). All optimizer PRs are optimisations; no new integration tests added.
Four O(N²) query-engine bottlenecks eliminated. No API changes, no file-format changes, no new dependencies.
PR #246 — #208: B+Tree Selective Lookup
get_facts_by_entity,get_facts_by_attribute,get_facts_by_entity_attributepromoted from#[cfg(test)]to production insrc/graph/storage.rs- New
selective_fact_fetchhelper inexecutor.rs: inspects query patterns for bound entity literals and bound attribute strings; calls index-driven fetches instead ofget_all_facts()when ≤4 distinct lookups detected as_ofqueries continue to use full scan (required for correctness)- New benchmark groups:
btree_lookup/entity_point,btree_lookup/attribute_scan
PR #247 — #202 + #203 + #204: Hash-Join Cluster
- #202 (
executor.rs):not/not-joinbodies pre-computed once intoHashSet<Vec<(String, Value)>>keyed on join variables; O(1) probe per outer binding replaces O(N) re-scan.normalize_valuehandleskeyword→Refrepresentation asymmetry in value position. - #203 (
executor.rs):or/or-joinbranches now evaluated from a single empty seed; branch results hash-joined back onto incoming bindings on shared user-visible variables (__-prefixed metadata keys excluded).or-joinprojects tojoin_varsbefore joining. - #204 (
matcher.rs):join_with_patterndetects join variable (entity position first, value position second), buildsHashMap<Value, Vec<Bindings>>once, probes per existing binding O(1).normalize_join_valuehandleskeyword→Ref. Falls back to nested-loop when no join variable found.
850 tests passing (844 passing + 6 ignored: confirmed or+neg-cycle stratification bug, deferred to post-1.0 backlog).
This is the v1.0.0 release. The public Rust API and the .graph file format are now stable
and committed to semantic versioning. File format stability is guaranteed from this release.
All cross-platform targets have shipped:
- 8.1a — Browser WASM (
BrowserDb,IndexedDbBackend,@minigraf/browseron npm) — v0.20.0 - 8.1b — Server-side WASM (
wasm32-wasip1/ WASI, Wasmtime/Wasmer CI) — v0.20.0 - 8.2 — Mobile bindings (Android
.aaron GitHub Packages, iOS.xcframeworkvia SPM, UniFFI) — v0.21.0 - 8.3a — Python (
minigrafon PyPI, pre-built wheels) — v0.22.0 - 8.3b — Java/JVM (
io.github.adityamukho:minigraf-jvmon Maven Central, fat JAR) — v0.23.0 - 8.3c — C FFI (
minigraf.h+ platform tarballs on GitHub Releases) — v0.24.0 - 8.3d — Node.js (
minigrafon npm, pre-built.nodebinaries) — v0.25.0
pkg/renamed tominigraf-wasm/,swift/renamed tominigraf-swift/— consistent top-level naming across all workspace packages (issue #179)@minigraf/browsernow published to npm on every tagged release (issue #179)@minigraf/wasipublished to npm on every tagged release (issue #178) — WASI binary packaged for Node.js WASI consumers- Per-platform READMEs added:
minigraf-wasm/,minigraf-node/,minigraf-ffi/python/,minigraf-c/,minigraf-ffi/java/
795 tests passing (788 passing + 7 ignored: confirmed or+neg-cycle stratification bug,
deferred to post-1.0 backlog).
- Node.js bindings published to npm as
minigraf. Install withnpm install minigraf. No build step required — prebuilt.nodebinaries for Linux x86_64/aarch64, macOS universal2, Windows x86_64. API:new MiniGrafDb(path),MiniGrafDb.inMemory(),.execute(datalog),.checkpoint(). Full TypeScript definitions included.
- C bindings distributed as GitHub Releases tarballs.
Download
minigraf-c-v0.24.0-<platform>.tar.gz(Linux/macOS) or.zip(Windows) from the release page. Each archive contains the prebuilt shared library plusminigraf.h. API:minigraf_open,minigraf_open_in_memory,minigraf_execute,minigraf_string_free,minigraf_checkpoint,minigraf_close,minigraf_last_error. Memory contract mirrors SQLite:minigraf_executereturns a heap-allocated JSON string; callminigraf_string_freeto release it. minigraf-c/: new workspace crate (cdylib+staticlib) —Cargo.toml,src/lib.rsminigraf-c/cbindgen.toml: cbindgen 0.29.2 configurationminigraf-c/include/minigraf.h: committed stable header (cbindgen-generated).github/workflows/c-ci.yml: PR test matrix on 4 platforms + header drift check.github/workflows/c-release.yml: release workflow — builds + packages platform tarballs, uploads to GitHub Releases
795 tests.
- Java desktop JVM bindings published to Maven Central as
io.github.adityamukho:minigraf-jvm:0.23.0. Add to Gradle:implementation("io.github.adityamukho:minigraf-jvm:0.23.0"). Fat JAR with embedded natives for Linux x86_64/aarch64, macOS universal2, Windows x86_64. API:MiniGrafDb.open(path),MiniGrafDb.openInMemory(),.execute(datalog),.checkpoint(). minigraf-ffi/java/: Gradle 8.11 project —build.gradle.kts,settings.gradle.kts,NativeLoader.kt(runtime native extraction from JAR resources), and Gradle wrapperminigraf-ffi/java/src/test/kotlin/.../BasicTest.kt: JUnit 5 suite (in-memory, transact/query, error handling, file-backed persistence).github/workflows/java-ci.yml: PR test matrix on 4 platforms (Linux x86_64, Linux aarch64, macOS universal2, Windows x86_64).github/workflows/java-release.yml: release workflow — cross-compiles natives on 4 platforms, assembles fat JAR, publishes to Maven Central via Sonatype OSSRH
795 tests.
- Python bindings published to PyPI as
minigraf. Install withpip install minigraf. API:MiniGrafDb.open(path),MiniGrafDb.open_in_memory(),.execute(datalog),.checkpoint(). Pre-built wheels for Linux x86_64/aarch64, macOS universal2, Windows x86_64.
src/lib.rs: added Feature Flags section and WebAssembly targets subsection to crate-level docs — browser feature,wasm32-unknown-unknowntarget switcher note, and WASI build commandREADME.md: updated "For Mobile Apps" section — replaced the cross-platform placeholder with current state, added Kotlin/Swift quick-start snippets and link to wiki integration guide- Wiki
Use-Cases.md: replaced Integration placeholder with full Android (Gradle setup, Kotlin API, error handling, threading) and iOS (SPM setup, Swift API, error handling, async) integration guides
795 tests.
minigraf-fficrate: UniFFI 0.31 bindings exposingMiniGrafDb(open, openInMemory, execute, checkpoint) andMiniGrafError(Parse, Query, Storage, Other) to Kotlin and Swift- Android
.aarrelease artifact, published to GitHub Packages (io.github.adityamukho:minigraf-android) - iOS
.xcframeworkrelease artifact, distributed via Swift Package Manager (Package.swiftat repo root) mobile.ymlCI workflow: cross-compiles Android targets withcargo-ndk, generates Kotlin/Swift UniFFI bindings, assembles AAR with Gradle, assembles xcframework withxcodebuild, and publishes both on every tagdocs-checkCI job inrust.ymlandrelease.yml— gates releases oncargo doc --all-featurespassing cleanly
release.yml: addeddocs-checktohostjob'sneedsandifconditionwasm-release.yml/mobile.yml: retry loops extended from 20 to 40 attempts;inputs.tag || github.ref_nameordering correctedminigraf-ffi/android/gradlew: removed inner double-quotes fromDEFAULT_JVM_OPTSand replaced xargs/sed eval block with directexec— fixes "Could not find main class" and garbled usage outputminigraf-ffi/android/build.gradle.kts: addedandroid { publishing { singleVariant("release") } }— fixes AGP 8.x "SoftwareComponent 'release' not found"mobile.ymlPackage.swift commit: pushes to unprotectedswift-releasesbranch and moves tag viagh api -F force=true— avoids branch-protection blocks and string/boolean type mismatch
795 tests.
browsermodule now appears on docs.rs: addeddocsrsto thecfggate anddoc(cfg(...))badge annotation (src/lib.rs)
- Browser WASM (
wasm32-unknown-unknown+wasm-bindgen):BrowserDbpublic API:open_in_memory(),execute(),checkpoint(),export_graph(),import_graph()BrowserBufferBackend— in-memoryStorageBackendover a flat page buffer, identical byte layout to the native.graphformatIndexedDbBackend— page-granular IndexedDB storage (one 4 KB entry per page); only dirty pages written on checkpointwasm-packbuild workflow (wasm32-unknown-unknown --features browser) generatingminigraf-wasm/with JS glue and TypeScript definitionswasm-bindgen-testbrowser integration tests (Chrome + Firefox viawasm-pack test)
- Server-side WASM (
wasm32-wasip1/ WASI):FileBackendverified under WASI's capability-based filesystem (no backend changes needed)- CI workflow (
wasm-wasi.yml) builds, unit-tests, and smoke-tests under Wasmtime and Wasmer on every push/PR - Thread-dependent tests gated with
#[cfg(not(target_os = "wasi"))]
- Cross-platform compatibility tests (issue #150):
tests/cross_platform_compat_test.rs— native round-trip (raw page byte copy) and fixture-readability teststests/fixtures/compat.graph— committed v7 binary fixture containing:alice :name "Alice"and:alice :age 30examples/generate_compat_fixture.rs— reproducible fixture generator (native only; no-op on wasm32)native_fixture_readable_by_browser_dbwasm-bindgen-test — loads native fixture viaBrowserDb::import_graph, verifies both facts
- Release workflow: WASM artifacts (WASI binary + browser tarball) built and attached on every tag;
cargo publishto crates.io on release
795 tests.
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)
-
<
EC2E
li>
WhereClause::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 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: basic absence, multi-clause, rule body, time-travel, negative cycle rejectiontests/not_join_test.rs— 14 integration tests fornot-join: 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
src/query/datalog/stratification.rs — DependencyGraph and stratify(): analyse rule dependency graphs at registration time; programs with negative cycles are rejected with a clear error
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- README status text updated to reflect the completed benchmark and publish-preparation work
- crates.io publication deferred to a later preparation release (API cleanup; 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 databases from that era 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<>>