8000 minigraf/CHANGELOG.md at v3 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
1435 lines (1122 loc) · 226 KB

CHANGELOG.md

File metadata and controls

1435 lines (1122 loc) · 226 KB

Changelog

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.

[Unreleased]

Added

  • OpenOptions::allow_overrides (#869, part of #862): the embedder lists, as Overridable variants, 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, from execute, query, prepare and WriteTransaction::execute; the list replaces the defaults rather than adding to them, and is not stored in the file. BrowserDb takes no options and uses the defaults. Overridable is #[non_exhaustive].

  • :query-memory-limit, limit bind slots and PreparedQuery limit setters (#870, part of #862). :query-memory-limit N replaces OpenOptions::query_memory_limit for one query, up or down; 0 is allowed and lets only a query that holds no state run (QRY-012 otherwise). It is gated by the new Overridable::QueryMemoryLimit, which is not in Overridable::DEFAULTS, so a database refuses it with API-025 unless the embedder lists it; BrowserDb always refuses it. :max-derived-facts, :max-results and :query-memory-limit take a $slot filled with BindValue::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_facts and with_query_memory_limit replace 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: TxOptions and Minigraf::begin_write_with (#871, part of #862). TxOptions (#[non_exhaustive]; fields synchronous: Option<SyncMode> and chunk_facts: Option<usize>, builders new, synchronous, chunk_facts) sets either option for one WriteTransaction; None means the database's value, and begin_write() is begin_write_with(TxOptions::default()). synchronous decides whether that transaction's WAL entry is synced, whatever OpenOptions::synchronous is, so a bulk load can commit with Normal on a Full database; a Full commit syncs the whole WAL, so earlier Normal entries 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_facts replaces OpenOptions::transaction_chunk_facts for that transaction, with the same clamp (1..=1,000,000). The options are not stored in the file, not gated by allow_overrides, and apply to that transaction only. begin_write_with fails as begin_write does (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). PreparedQuery is Clone: 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. BrowserPreparedQuery gains withMaxDerivedFacts(n), withQueryMemoryLimit(bytes) and withMaxIterations(n), each returning a new prepared query that keeps the database open; a number at or above the platform's usize::MAX means no limit, and a negative, fractional or non-finite number throws [API-017]. BrowserDb still takes no open options. docs/CONFIGURATION.md lists which settings every binding exposes.

  • Configurable recursion round limit (#861, part of #862). OpenOptions::max_iterations sets the most rounds one stratum of a rule query may run (default 1,000,000, see Breaking changes); :max-iterations N or $slot replaces it for one query, up or down (INT-012/013/014/015 as for :max-derived-facts), gated by the new Overridable::MaxIterations, which is in Overridable::DEFAULTS; PreparedQuery::with_max_iterations replaces 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() and begin_write_owned_with(TxOptions) return an OwnedWriteTransaction: execute, commit and rollback with the borrowed WriteTransaction's results and errors, but it is Send and '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 a WriteTransaction refuses are INT-001 on 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 gets INT-001 on whichever thread runs the statement. Every language binding's transaction is built on it. BrowserDb.beginWrite() returns a BrowserWriteTransaction with a synchronous execute, a commit() that applies the staged facts as one transaction and resolves after the IndexedDB flush, and a rollback(); freeing it open rolls it back. While it is open, BrowserDb's execute, query, prepare, checkpoint, importGraph, a second beginWrite and a prepared query's execute/query throw or reject with [INT-001]; a finished one's execute and commit are [API-018]. No new error codes; no file format change.

Changed

  • INT-001 text (#824): a WriteTransaction is already in progress on this thread; use tx.execute() instead now reads a 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 integer is now {} must be a non-negative integer, because :query-memory-limit accepts 0. The code and when :max-derived-facts/:max-results raise it are unchanged.

  • INT-020 round-limit text (#861): Max iterations (N) exceeded. Possible infinite recursion or cycle in rules. now reads Max 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 window avg: 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 ordinary sum and avg are now charged to the query memory limit.

  • Align ordinary aggregate value handling with windows (#789, v3.0.0). sum and sum-distinct skip nil and nonnumeric arguments; min/max compare 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.

Testing

  • Owned and browser transaction tests (#824): tests/owned_tx_test.rs checks 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.

Documentation

  • Configuration reference (#869): docs/CONFIGURATION.md lists every OpenOptions setting, where else it can be changed, and how allow_overrides gates 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.

Performance

  • Released binaries use full LTO (#796). The dist profile cargo-dist builds releases with no longer overrides release'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.yml now 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. See docs/benchmarks/796-dist-lto.md.

  • Sort set element encodings without a stable sort (#794). set_elements sorts its elements' byte encodings with sort_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 with sort_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-scalar min/max, and Integer/nil sum-distinct. Effective grouping tuples may include hidden :with keys, 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 :with Sort 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 Sort when 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. :offset and :limit then 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 and collect-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 :with or windows, and only built-in count/count-distinct holds 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 in Value::Ord order. A unique single-kind group key can also remove the final ascending Sort; mixed numeric keys retain numeric Sort. Fallible aggregates, callbacks and collect-set retain full-input validation and later errors. Small selective grouped queries pay proof startup cost, and a single growing group is slower (see docs/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 and collect-set retain 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 (see docs/BENCHMARKS.md).

Breaking changes

  • 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. Set OpenOptions::max_iterations(1_000) to keep the old limit.

  • Value is #[non_exhaustive] and has a Set variant (#316). A match on Value outside 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-030 or STG-031 instead of being read. If the newest meta page is damaged after its WAL was already deleted, open fails with STG-033 rather than silently going back to the previous checkpoint. New codes STG-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 with STG-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 codes STG-036 (dictionary entry missing) and STG-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 with STG-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() and wal_checkpoint_threshold docs 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-003 and STG-004 are deprecated (no longer emitted, still registered, never reused). STG-005 and STG-006 message text changed.

  • Empty or inverted valid-time windows are rejected (#436). A transact whose effective window for any fact ends at or before it starts fails with the new API-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-from is the transaction time. This applies to execute, each WriteTransaction::execute statement 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 several execute calls in one WriteTransaction write the same (entity, attribute, value), in any mix of transact and retract, 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 before commit() and was gone after it. Two windows of one fact in separate statements, which were API-011, now commit the later window. API-011 is limited to one transact; tx.execute() raises it and stages nothing, and the transaction stays usable. LogWriter keeps the same rules (see the log writer entry).

  • A :find variable that nothing binds is an error (#406). A query whose :find names a variable that no :where clause binds now fails at parse time with the new PRS-080, naming the variable, from execute(), query() and prepare(). Before, it returned no results, so a typo in :find looked the same as "no matching data". The same check covers a window function's argument, :partition-by and :order-by variables. A variable counts as bound when it appears in a pattern, a rule call, an expression binding, an or-join join vector or every branch of an or; one that appears only inside not/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 :find are now resolved before the query runs: an unknown aggregate fails with the new QRY-010 and an unknown window function with the new QRY-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 with INT-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 latest transact. A later assertion of the same fact replaces the earlier window, which stays visible through :as-of of the earlier transaction. Before, every window ever asserted stayed live until a retract, so closing an open window with (transact {:valid-from vf :valid-to D} ...) left the open one live and :valid-at after D still found the fact. Closing, extending or reopening a window is now a plain transact with the new bounds; retract still 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 one transact is rejected with the new API-011, and nothing is written. Data written before this check with two windows in one transaction keeps both.

  • The uuid dependency no longer enables v4 (#439). The library only makes v5 UUIDs, so the v4 feature and, on browser wasm, js are gone from its uuid dependency, and with them getrandom. A crate that called Uuid::new_v4 only because Cargo feature unification turned v4 on through minigraf must now enable uuid/v4 itself. 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.0 with -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 within f64::EPSILON until #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 :where text. 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 the wasm feature, 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 :where of queries with and without rules; inside or, not and 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 ?x wherever the planner ran it, so when a pattern also bound ?x the answer depended on whether that pattern ran first, and differed under the wasm feature, 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 under wasm. [(+ ?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 :where text that binds it, expressions included. This applies to or branches, not bodies and rule bodies too, on the executor and on the streaming engine.

  • A value a not / not-join body 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 an or branch and in a rule body, substituted each outer value into the body as a literal, so a float matched within f64::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 :e1 and [: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.0 and -0.0 are equal and NaN equals nothing. A literal constant in the body still matches floats within f64::EPSILON.

  • An or / or-join gives the same answer on every executor path (#546). The executor evaluated or branches on two paths, and their answers differed. The joined path, taken unless a branch held a not, 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 an or-join branch 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 an or returns are still distinct. Also, with no :valid-at, :db/valid-at in 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-count and :db/tx-id read 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 :where text 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 enclosing not / or; it crosses a not-join / or-join only 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 (:alice and 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 the wasm feature: in [?e :person/name ?n] [?e :person/age ?a] [?e :db/tx-count ?t], ?t could be the tx-count of either fact, and is now the :person/age fact's. Three further changes follow: (1) with no source, the query fails at parse time with the new PRS-081 instead 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) inside not, not-join, or and or-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 by or. :db/valid-at is 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 :where text that binds it (#545); a :db/valid-at pattern 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 :bob with source order and the keyword :bob stored by :friend natively. 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 wasm feature does nothing (#594, #596). The wasm Cargo feature kept patterns, not clauses and or branches in source order and turned off the or short-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 uses browser). 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) sum over floats is exact: the values are added without rounding and the total is rounded once, so 1e16 + 1.0 + -1e16 + 1.0 + 0.5 gives 2.5 in every order (before, 1.5 or 2.5). Window sum and avg likewise. (2) sum over integers saturates once, at the end: i64::MAX + 1 + -1 gives i64::MAX (before, i64::MAX - 1 or i64::MAX by order). (3) Window sum over integers adds them exactly; before they were added as floats, so a running total above 2^53 lost its low digits. (4) min / max of floats follow one order: -0.0 before 0.0, NaN after every other number (before, the first of two such values seen won). Window min / max use 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-by under 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-by values are peers: rank gives them the same rank, and sum, count, min, max, avg and registered window functions give every peer the value at the last peer, as SQL does; row-number numbers peers in the order of the other variables :find names. For example, with ?s of 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 :y by :order-by gave 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-by and :order-by must be plain :find variables, or the query fails at parse time with the new PRS-082; before, any other variable read nil on every group row. The error a failing aggregate query reports is the first error, in :find order, 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.0 and 0.0, or NaNs) returns the least of them in that order, so -0.0 when the group holds it; before, it returned the first one the plan met.

  • Parse errors that were INT-019 now carry their own PRS codes (#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/or body, pattern and rule) used one internal code, INT-019, with a datalog parser: prefix. Each now has its own Parser-category code, PRS-083 to PRS-107, documented in docs/ERROR_REFERENCE.md; the message is the old text without the prefix. A retract fact without exactly three elements was INT-000 and is now PRS-090. Code that matches on INT-019 (or INT-000 for retract arity), or on the Internal category, for these errors must match the new codes. INT-019 stays 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.pest and docs/GRAMMAR.md do 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 ?b as a second column. It now fails with PRS-061.
    • A :where list 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 with PRS-097; a comparison is an expression clause, [(< ?a ?b)].
    • A rule named by a variable or an operator, (rule [(?r ?a) …]), fails with PRS-105, and a rule head argument that is not a variable, (rule [(r ?a :c) …]), with the new PRS-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 with PRS-025, PRS-029 or PRS-063.
    • (query []) fails with the new PRS-111; :find, :where or :with with nothing after it (an empty :find returned one empty row) with the new PRS-112; a keyword the query vector does not know, such as :foo, with the new PRS-113 (it was ignored when another keyword followed, and PRS-061 otherwise).
    • 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 new PRS-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.

  • One rule limit, max_derived_facts, on the total facts a rule query derives (#553). OpenOptions::max_derived_facts and :max-derived-facts now 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_results and :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_results now defaults to no limit of its own, so it can lower the limit but not raise it: a caller who raised max_results above 1,000,000 must raise max_derived_facts instead. Neither ever limited the rows a query returns, whatever the name said; :limit (#306) will. A caller who lowered max_derived_facts to cap each iteration now caps the total, so a long recursion whose iterations each stayed under the limit can now fail with INT-020. The INT-020 text now reads evaluator: 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 or nil there now fails the query with the new code QRY-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 with INT-022, and a rule with not, not-join, or or or-join silently dropped the row. A head variable the body does not bind now fails with INT-022 on the not/or path too, which also dropped the row.

  • A fact a rule derives has no metadata (#548). :db/valid-from, :db/valid-to, :db/tx-count and :db/tx-id read from a derived fact ((reach ?x ?y) [?x :reach ?y] [?x :db/tx-count ?c]) now bind nil, 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-at still binds the query's valid time.

  • A not or nested or inside an or branch sees a variable an expression of the branch binds (#551). The executor applied a branch's patterns, then its nested ors, then its nots, then its expressions, so a not or nested or reading a variable that only an expression of the branch binds saw it unbound. A branch now runs in the order a :where does (#625), and the not sees 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 (the not matched any fact, so the first branch kept no row) and now returns :y and :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 or branch (of the query or of a rule it evaluates) or a not / not-join body, is now looked up when the query opens, as top-level predicates and :find functions already were (#414): an unknown one fails with QRY-007 whatever the data. Before, the executor looked it up only when a row reached it. In an or branch, a query whose rows never reached the predicate returned an answer, and now fails. In a not / not-join body the failure was swallowed: the body had no row, so the not kept 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 every API- code (a bad argument, a closed object, a read-only write, and the new bind-value codes) as MiniGrafError.Other; they now raise the new MiniGrafError.Api, with the same message. Code that matched Other for these must match Api, and an exhaustive when (Kotlin) or switch (Swift) over MiniGrafError needs the new case.

  • Bind-value mistakes are API errors (#181). A missing bind value, a bind slot in an attribute position and a bind value of the wrong kind for its slot were INT-033, INT-034 and INT-035, codes for internal faults, although each is the caller's mistake. They are now API-022, API-023 and API-024, with the same messages, from prepare, PreparedQuery::execute and PreparedQuery::query. A caller that matched the INT codes must match the new ones. INT-033–INT-035 are deprecated (no longer emitted, still registered, never reused).

Added

  • Bounded sliding row frames (#183). :rows N preceding adds 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 codes PRS-124 and PRS-125 report malformed frames and frames on position functions.
  • lag and lead window functions (#182). (lag ?value :over (:partition-by ?group :order-by ?key)) reads the previous row's value; lead reads the next row's. Both use the established total partition order, including named-variable tie breaks, and return nil at partition edges. :desc reverses 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-030 is deprecated and no longer emitted.
  • Optional distinct query results (#493). A bare :distinct flag removes repeated final :find tuples after aggregates and windows and before :order-by, :offset and :limit. Queries still return bags by default; :with keeps its aggregate grouping behavior. Equality follows count-distinct (including one NaN and one floating zero), with canonical -0.0/NaN output, also inside sets. The distinct keys count toward query_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_predicate and Minigraf::try_register_aggregate take closures that return Result<_, UdfError> (UdfError is exported at the crate root). A call that returns Err fails the query with the new QRY-014, user function `<name>` failed: <message>, at the first failing call: it is never read as false or nil, and no later call of any user function is made for that query. execute and PreparedQuery::execute return it; a cursor treats it as any other read error, returning the rows of the batches finished before the failure, then the error, then None. register_predicate and register_aggregate are 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 a WriteTransaction those calls return INT-001 on 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) and db.registerAggregate(name, { init, step, finish }), both synchronous. A function receives each value as a { type, value } object, as @minigraf/node's FactValue (an integer's value is a bigint, 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 and finish(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 with QRY-014 and the error's message (else String(e)), and so does a predicate returning a non-boolean or a finish result that is not a value. A UDF may call query, prepare and the register* methods synchronously; execute, checkpoint and importGraph started 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 of unsafe.
  • :limit N bounds 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 10 reads one internal batch. Without :order-by which rows is unspecified, and may differ between plans; they are always rows of the unlimited answer. :limit 0 returns no rows. It applies after aggregates and windows, to rule queries and to cursors, and a prepared query takes :limit $n, bound with BindValue::Val(Value::Integer(n)). A value that is not an integer >= 0 or a slot fails with the new PRS-115; a repeated :limit with INT-012 and a missing value with INT-013, as for the other query options. :max-results and :max-derived-facts still limit only the facts rules derive.
  • :order-by and :offset (#306). :order-by [(count ?e) :desc ?name] orders the rows a query returns by :find elements (plain variables, or aggregates and windows written as in :find), each :asc (the default) or :desc; ties are broken by the other :find columns ascending, so every position of an ordered answer is decided and every plan returns the same list. Values compare as windows and min/max compare them: numbers by value (an integer before an equal float, -0.0 before 0.0, NaN after every number), then strings, booleans, refs, keywords, nil and sets. :offset N skips the first N rows of the ordered answer, before :limit, so :offset 20 :limit 10 is the third page of ten. A new Sort operator reads its whole input; under a :limit it keeps only the best offset + limit rows, so :order-by [?v :desc] :limit 10 over 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 toward query_memory_limit. New codes: PRS-116 (:offset without :order-by: without an order, pages could overlap or miss rows), PRS-117 (an entry that is not a :find element) and PRS-118 (:order-by is not a non-empty vector, names an element twice, or has a misplaced or unknown keyword). PRS-115 now also covers :offset. Inside a window's :over, :order-by is 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 as Value::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 when Value's order says so: 0.0 and -0.0 are one element (-0.0 is kept), every NaN is one element, a keyword never equals a ref and 1 never equals 1.0. A set cannot hold a set (ValueSet::new fails with the new API-021; in query text, PRS-119); its elements are literals only (PRS-120), and an unclosed #{ is PRS-121. In format v8 a set has value tag 0x08, 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 #{...} and BrowserDb returns them as JSON arrays, in Value's order.
  • Set-membership predicates (#316, PR 2 of 3). [(in? ?role #{:role/admin :role/owner})] keeps rows whose value is in a set, and not-in?, any-in? (the two sets share an element), all-in? (every element of the first is in the second) and none-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.0 is in #{-0.0}, NaN is in a set holding NaN, a keyword is not a ref. in? of an empty set is false and not-in? true; all-in? and none-in? of an empty first set are true and any-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, so not-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-of and :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)]. ?e must 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-join bodies and or branches; 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 ?variable then an attribute keyword, a bind slot included) and QRY-015 (a value found is itself a set: bind a set-valued attribute with a pattern). The set it builds counts toward query_memory_limit (QRY-012). collect-set is read only as a whole clause (PRS-067 inside another expression) and cannot be registered as a function.
  • BrowserDb binds sets (#316). A prepared query's binding can be a set: { type: 'set', value: [1, 2] } or a JS Set. 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 is API-017, a set inside a set API-021. Any other element, and a plain array as a binding, is a TypeError, 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 Python set/frozenset, a Kotlin/Java Set, a Swift Set of MinigrafSetElements, a JS Set in Node.js and the browser; a UUID element is a ref. A set inside a set is API-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 Fact before matching, held each row's bindings in a map and copied the database into a FactStorage for every rule query, no longer runs queries: its query path, PatternMatcher and 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 of C, #518); a later pattern is an IndexNestedLoopJoin when its entity is bound and a HashJoin otherwise, 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-join is an AntiJoin and or / or-join a Union and Distinct: 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-of with 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 against OpenOptions::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 in tests/answers/. Measured with the #394 envelope at 1M facts, executor → engine, p50 and peak RSS (docs/BENCHMARKS.md has every shape at 100K, 1M and 10M): [?e :name "…"] 288 ms → 22 µs; an attribute scan of 166,666 rows drained with next_batch(1000) 58 → 7 MiB (458 → 7 MiB at 10M); [:p :follows ?f] [?f :name ?n] 796 ms → 50 µs; an entity-bound not 760 → 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); an or-join of 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 :manager 51.0 s → 3.21 s and 4.7 GiB → 318 MiB; :as-of with 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). transact and retract are 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 new OpenOptions::transaction_chunk_facts (default 65,536, kept between 1 and 1,000,000): Minigraf::execute of a larger statement, or the WriteTransaction statement 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: its tx_id is the time it became large, not its commit time (a default :valid-from takes that time too), and errors from different chunks of one statement come in chunk order. In a large WriteTransaction a 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 at commit; a handle that has failed is refused when the transaction becomes large (STG-046) rather than at commit; and inside the transaction :as-of its own transaction number sees every finished statement, since they share one number (staged statements of a small transaction each have their own until commit). 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 (one execute, or a WriteTransaction of 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 one execute) 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 WriteTransaction read 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 by query_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 an or's table, a distinct set, an aggregate's groups (count-distinct and sum-distinct members 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 through try_reserve. The new OpenOptions::query_memory_limit (default usize::MAX, off) turns a query that would hold more into the new QRY-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 is QRY-012 too, in place of the abort. Rows passing between operators and the answer execute collects 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), from MinigrafError::invalid_argument, and API-018 (a log writer used after finish or close), from MinigrafError::closed. BrowserDb.query(datalog) returns a BrowserCursor with vars(), nextBatch(maxRows) (rows as JSON, as in execute(), or undefined at the end) and close(). The shared binding shape is in docs/superpowers/specs/2026-10-07-binding-etl-surface-design.md. The wasm32 test build compiles again (a duplicate page_cache_capacity and tempfile tests on wasm32).
  • Fact log: Minigraf::fact_log() (#430). Streams every fact version in the database as FactRecords (entity, attribute, value, tx_count, tx_id, valid-time bounds, and whether it is an assertion or a retraction), without Datalog. FactFilter selects attributes (exact or by prefix), entities and a tx_count range, checked on index keys so excluded records are never decoded. FactOrder::Tx (the default) returns records in ascending tx_count; format v8 has no index in transaction order, so it holds at most FactFilter::window records (default 262,144) and scans the selected keys once per window. FactOrder::Storage reads in one pass. The log reads the database as it was when it opened. Writes, checkpoints and rebuild_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. Like Cursor, a FactLog is owned and Send. Rust API only for now; the bindings follow.
  • Raw log writer: LogWriter (#431). Builds a new database from explicit FactRecords, the load step of an offline migration after fact_log() on a read-only source. Each record keeps its tx_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-of on it shows the state before. advance_tx_count keeps the source's counter after trailing purged transactions. The checks are structural only: order (new API-015), one tx_id per transaction (new API-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 is API-019, a second window of one fact in one transaction API-011, and an assertion and a retraction of one fact in one transaction the new API-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.md describes 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>.partial and renamed into place by finish, so a crash or an abandoned build never leaves part of a log at path; an existing target or WAL is the new STG-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 .graph file 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 new STG-042 (nothing is created). transact, retract, begin_write, checkpoint and rebuild_indexes fail with the new API-014; queries, cursors, fact_log, verify and rule registration work, and dropping the handle does not checkpoint. page_cache_size applies as usual, so a tool can keep the whole file resident. Rust API only for now; the bindings follow.
  • Cursor API: Minigraf::query() and PreparedQuery::query() (#432). Both return a Cursor that 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 as execute(); the API is shaped so the engine can later produce rows as they are pulled without breaking callers. query() rejects transact, retract and rule with the new API-012, and a query with bind slots with API-010. execute() is unchanged. Like a fact log, a cursor pins the committed generation it read (#543): checkpoints and rebuild_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() and Minigraf::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 an IntegrityReport with one structured error per finding: an index that holds different facts than EAVT or a different count than the meta page (new STG-038), keys out of order or a page reached twice (new STG-039), dictionary maps that disagree (new STG-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 new STG-041 and 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 a BrowserPreparedQuery, whose execute(bindings) returns the JSON execute returns and whose query(bindings) returns a BrowserCursor. Bindings are an object of slot name to a plain value (a boolean; a number, an integer when Number.isInteger accepts it and a float otherwise; a bigint; a string; null) or a typed { type, value } form for the other kinds (float with no fractional part, keyword, ref, entity, txCount, timestamp, anyValidTime). A value that is not a bind value is a TypeError, and a typed value that does not fit its type (a bad UUID, a negative tx count) is API-017. The other bindings follow.
  • Prepared queries in every binding (#181). prepare(text) returns a prepared query with execute(bindings) (the JSON execute returns) and query(bindings) (the binding's cursor) in Python, Java, Android, Swift, Node.js and C, as BrowserDb.prepare does 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 no close (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 with API-018.
  • BindValue::Entity and BindValue::Val(Value::Ref(..)) fit each other's slots (#181). An entity slot ([$e :attr ?v]) takes Val(Value::Ref(uuid)), and a value, expression or rule-argument slot takes Entity(uuid) as the ref. Before, each was INT-035 in the other's position, and a rule argument needed Val(Value::Ref(..)). A binding can now pass a host UUID without knowing where its slot is.

Performance

  • 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_5m fell 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.

Fixed

  • 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, returns QRY-012 if 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, not bodies and or branches treat the keyword :bob as equal to a ref to the entity :bob names, but [(= ?g :bob)] compared the two values as they are, so with ?g bound 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, not clauses and binding expressions, against a keyword constant, a keyword value or a bound Val(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, verify and rebuild_indexes inside a WriteTransaction return INT-001 instead of deadlocking (#734). Called on a thread that holds a WriteTransaction, they waited on the write lock that the transaction already held, so the thread hung. They now return INT-001, as execute, query, fact_log and begin_write already did, and the transaction stays usable. prepare and PreparedQuery::execute/query make the same check; they could not deadlock, but they ran inside the transaction where execute/query refused. checkpoint deadlocked in every release since WriteTransaction was 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 $slot of a prepared query, matched every stored float within an absolute f64::EPSILON (about 2.2e-16), so [?e :m/x 0.0] returned an entity whose :m/x is 1e-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 about 2.2e-16 in magnitude were affected: every one of them matched 0.0, and 1e-20 matched 5e-20. Float constants now compare by IEEE ==, as joins, predicates and the indexes do: 0.0 matches -0.0 and nothing else changes. The fix covers patterns in queries, rule bodies and not / or bodies, 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 new reach fact with every magic fact, then probed :next once 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. Nightly recursion benchmarks (bench profile): fanout/w10_d3 4.94 s → 22.6 ms (it was about 94 ms on the executor before #548), chain/depth_100 699 ms → 41.5 ms, chain/depth_10 1.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 a HashJoin holding every :manager fact, 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 an IndexNestedLoopJoin on 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.0 and -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_ancestors 100 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. Nightly recursion benchmarks (in memory, bench profile): the new ancestors/w10_d4 97.7 → 15.1 ms, chain/depth_100 41.4 → 20.8 ms, fanout/w10_d3 22.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), so ancestors stays above its 0.27 ms before #694.
  • BrowserDb errors carry their error code (#673). The browser API (@minigraf/browser) rejected with the error's top-level message only, such as Unknown command: nosuchcommand, while every other API reports [PRS-001] Unknown command: nosuchcommand. Every BrowserDb rejection now goes through MinigrafError, 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::execute and query ran a rule query with the default max_derived_facts of 1,000,000 whatever OpenOptions set; they now use the database's limit, as execute does, and its query_memory_limit.
  • An or branch in a rule body knows the registered predicates (#548). A rule with or/or-join evaluated its branches with the built-in functions only, so a predicate registered with register_predicate in 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, or or or-join, :db/valid-from, :db/valid-to, :db/tx-count and :db/tx-id read 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 or or not, a call from a rule the query calls with free arguments, or a rule-body call whose argument nothing bound yet — either failed with INT-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 :c edges 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-at pattern in a not-join body binds the query's valid time on every path (#615). When the body also held an expression, sat in an or branch, or ran in a query that invokes a rule, the body bound nil, 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-at value (or the current time) as the other paths do. Present since v0.13.1; v2.x is affected too.
  • A :db/valid-at pattern in a rule definition binds the query's valid time (#617). The rule evaluators never received it, so in a rule body the pattern bound nil and the query failed with INT-022; in a rule's not / not-join body it bound nil, so the negation never matched on it; and in a rule's or branch it bound the current time instead of the query's :valid-at. Rule bodies, their negation bodies and or branches now bind the query's :valid-at value, the current time without one, and nil under :any-valid-time, as patterns outside rules do. v2.x is affected too.
  • An or / or-join branch 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 ?v unbound and the branch matched nothing: the or silently 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 with not / not-join already were.
  • An expression reading a variable only an or binds keeps its rows (#625). The planner places expressions among the patterns and nots, and or / or-join run after them, so an expression such as [(> ?x 1)] in [?e :n _] (or [?e :n ?x] [?e :ok ?x]) [(> ?x 1)] ran before ?x was bound and dropped every row, on the executor, on the streaming engine and in rule bodies. Such an expression now runs after the ors, before the nots that wait for them, as those nots already did.
  • A rule calling a rule with not, not-join, or or or-join gets 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 any cur row existed when cur had a not-join, and returned nothing; a caller that had a not or or of 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, or or or-join returns 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_results now 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 or that shares no variable with the earlier clauses returns its cross product (#610). Such an or — [?x :edge ?z] (or [?p :edge :b] [?p :edge :c]) — returned no rows when no branch had not, not-join or 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-join still 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-at or :any-valid-time (#603). Binding a prepared query's slots dropped any :as-of or :valid-at that was not itself a slot, so PreparedQuery::execute and PreparedQuery::query ran 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-time failed with INT-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.yml workflow runs the wasm-bindgen tests in headless Firefox and Chrome, runs the unit and integration tests on wasm32-wasip1 under Wasmtime, and lints both targets with clippy -D warnings. Fixes found on the way: BrowserDb skipped 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 longer unwrap() (on wasm32 a panic aborts the whole instance). IndexedDB keys that are not whole page numbers are rejected instead of truncated. The nine BrowserBufferBackend tests 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.
  • BrowserDb stores 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 each transact, retract, checkpoint() and importGraph() 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's page_count with the file's length. A truncated file (an interrupted copy, a partial download) opened, and its queries and checkpoints then failed with INT-049 after the WAL had accepted writes. A meta with a page_count far past the end opened too: its next checkpoint wrote at that page id, leaving a sparse file of up to 4 EiB, and verify() 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 with INT-049 instead of writing a meta that counts pages the backend does not hold, and verify() reports a page_count past the end as STG-044 and stops its page scan at the end of the file. BrowserDb is not checked: it keeps only live pages (#440), so its store has no length to compare. New code STG-044.
  • A WAL from a later checkpoint is refused at open with STG-047 (#535). Opening an older .graph next to a .wal created 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-045 unless 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 is STG-045, read-write and read-only, and the file is not modified. A zero-length file is still a new database. New code STG-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 failed fsync the 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 with STG-029. Now every later write, checkpoint and index rebuild through that handle fails with the new STG-046, queries keep working, and the close-time checkpoint is skipped. Reopening the database recovers everything committed: it is all in the WAL. A rebuild_indexes() refused with STG-041 writes 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-007 until the database is reopened or a checkpoint() 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 with STG-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-020 on databases with 1,000,000 or more live facts (#528). The recursive evaluator counted the database's own facts against max_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_results now counts only the facts the rules derive, so the :max-results 3000000 workaround 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_count values and :as-of history 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 .graph file (#476). The sentinel was documented to suppress the close-time checkpoint, and Minigraf'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 explicit checkpoint() 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 ...)") and WriteTransaction::commit all did this. They now add the whole transaction under one lock, as does WAL replay. Stored data was never affected. Affected WriteTransaction since v0.5.0 and execute since 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-049 instead 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 with INT-049. verify() already reported such a tree.
  • A fact with more than four elements is rejected (#649). transact read 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 new PRS-108, and the transaction writes nothing. A fact of five or more elements whose 4th is not a map now gets PRS-108 instead of PRS-047. Present since v0.4.0; found by the grammar verifier (#643), whose FactArity drift entry is removed.

Internal

  • 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.md records 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 N point 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-of point 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 seek that 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.

Tests

  • 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 std synchronization.

  • 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 Reader still 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 WriteTransaction whose 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 passing verify. The fault matrix covers a large WriteTransaction at every write and sync, and with test-hooks the 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_count and 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.rs opens 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 new golden-corpus job in policy.yml fails a pull request that changes one. The generator is a standalone crate pinned to minigraf = "=2.0.3" (tests/golden/gen/v7/). On v3, each v7 file must also carry a v8 meta page after its first open, and seven v8 files are frozen alongside them, written by the v3 branch at 23aa57c (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.rs runs random sequences of transact (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 .graph file 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 N and :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, from execute and tx.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.rs used 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 after execute returns; it checkpoints after every transaction, every third, never (WAL replay), automatically every 2 writes, or every third with SyncMode::Normal. After a SIGKILL at 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, with verify clean. 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_SEED replays a failure.

  • Fuzzing reaches the v8 decoders, and the corpus is kept between runs (#387, #375). On v3, the btree_page and fact_page targets still wrote the pre-release single-header layout and stopped at STG-032 on every input, and file_header rarely got past the meta CRC. The page targets now start from a real v8 file built through the public API (rewritten with LogWriter at 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_page fuzzes any leaf or internal node of the five trees, file_header fuzzes either meta slot (with or without a matching CRC, with or without the other slot), and fact_page fuzzes the fact page of a v7 golden file through migration and the read-only load. Each run executes one input-chosen probe query or verify(). The nightly fuzz workflow now runs every target in its own parallel job, on main and v3 on alternating crons, and keeps each target's corpus in the Actions cache. A new weekly workflow (fuzz-corpus.yml) minimizes the cached corpus with cargo fuzz cmin, keeps every committed seed_* file, and proposes the result as a pull request.

  • Two fuzz targets check results, not just crashes (#387). ops_sequence decodes 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 in tests/reference_model, so the proptest test and the fuzzer share one oracle. corrupt_file damages 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.rs grows 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-of on 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), and verify() 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.yml uploads weekly, alternating main and v3. soak_short runs 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.yml had never checked anything: the instrumented test run failed (the lock tests could not find pid_ns_helper), the report it grepped was empty, and every module was skipped with a warning, so the job passed. It also read cargo llvm-cov --text, which has no per-file percentages, and named btree_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 against coverage-thresholds.toml with scripts/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, save with nothing pending, into_backend with pending facts, rebuild_indexes with 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-only MutexStorageBackend, and two threads missing the same page in the page cache (before, only a lucky race reached that path). Branch coverage of wal.rs went from 83% to 95%, packed_pages.rs from 86% to 100%, persistent_facts.rs from 82% to 88%, and btree.rs from 85% to 85.5%. A follow-up brought the storage files the issue named to 90% branch coverage or more: btree.rs to 95.7%, persistent_facts.rs to 91.7% and index.rs to 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.rs builds 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 new envelope Cargo profile (release at opt-level 3, as an application compiles the library; release itself is opt-level "z"). docs/BENCHMARKS.md and 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.py in minigraf-python) adds about 70 µs per query. .github/workflows/envelope.yml runs 100K and 1M on demand, and a 10K smoke pass on PRs that change the harness. The nightly Criterion query/* 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.rs covers 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-081 for each shape with no source, reading the outer fact from not, not-join (listed and unlisted variable), or (never a sibling branch) and or-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 crossed not-join gains. 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-hooks feature) turns off every place the native build reorders: the pattern selectivity sort, the not/not-join and or/or-join cost sorts and the or short-circuits (#250), and the engine's HashJoin build side. tests/plan_independence_test.rs runs 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, through execute and a cursor, with the switch on and off, and requires the same rows. Under test-hooks the 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 whose not and or rules recurse, rules that read their stratum outside a top-level invocation, derived facts seen by every pattern, the identity of derived facts, nil metadata, heads, both INT-020 limits, and rule queries with aggregates, not-join, or and 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,500 reach facts 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 default max_derived_facts then stops the same rule with INT-020. The #394 envelope has a recursive shape: 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, or QRY-012 naming Aggregate under 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), or QRY-012 naming HashJoin under 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 found Aggregate'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-hooks feature) forces the engine's not / not-join bodies to run hashed or correlated. tests/plan_independence_test.rs runs 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.0 and -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.rs checks 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.rs checks the changed or / or-join answers through execute and a cursor, in memory and after a checkpoint. tests/plan_independence_test.rs gains disjunctions (keywords meeting refs, floats near zero, long strings as keys, branches naming different outer variables or no join variable, a branch with not, an or with nothing before it, a cross product, :db/valid-at in a branch), run with branches chosen, forced hashed and forced correlated. The property test generates or and or-join over 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.rs unit 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.rs checks the changed answers; tests/plan_independence_test.rs gains aggregate, window and mixed queries over ties, keywords and refs, and floats.

  • tests/snapshot_atomicity_test.rs races a writer against a reader: a query must see each transaction whole, whether written with execute or a WriteTransaction (#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_eval fuzz 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. A not-join body 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 the v3 corpus (4,783 inputs) coverage drops by 7 of 9,719 edges. The input is added as a seed, as is the btree_page input of the cursor fix above.

  • Kani proofs of the key and value encoders (#627). src/storage/keys/verification.rs and src/storage/index/verification.rs hold 12 Kani encoder harnesses, compiled only under cfg(kani). They prove for every input that the v8 key encoders (put_int, put_uint, put_tx_desc, put_valid_to, float_bits) and encode_value keep the order the indexes rely on, that every NaN encodes to one value, and that float_bits round-trips. .github/workflows/kani.yml runs them on pull requests that touch the keys and nightly on main and v3. The original #627 scope left decoders out; #693 above adds scalar decoder proofs. Strings, sets, packed pages, WAL and Reader's error mapping remain under fuzz, property and differential tests. Test-only; Cargo.toml declares the kani cfg so normal builds do not warn.

  • Parser vs grammar verifier (#643). src/query/datalog/parser/verifier.rs checks, for every generated input, that the parser rejects what tests/grammar/grammar.pest rejects, 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) through parse_edn, and every bracket-balanced sequence of up to MINIGRAF_GRAMMAR_BOUND (default 3) of 32 tokens in 15 command templates through parse_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 in KNOWN_DRIFT by 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.pest now 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 (and and inside an or) are never rule names; literal keywords end at a word boundary (:find?x is one keyword).

  • The grammar documents comments and non-ASCII identifiers (#650). The parser has always accepted both; tests/grammar/grammar.pest and docs/GRAMMAR.md now 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's char::is_alphabetic and char::is_numeric define 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.

Documentation

  • Browser WASM is a Tier 1 binding. From v3.0.0, @minigraf/browser is 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 from tests/grammar/grammar.pest, the grammar the parser verifier checks the parser against, so the documented syntax is the tested one. tests/grammar_doc.rs renders it with pest_meta (a new dev-dependency, already built for pest_derive) and fails when the file is stale; UPDATE_GRAMMAR_DOC=1 cargo test --test grammar_doc rewrites 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 under SyncMode::Full and SyncMode::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, or fact_log into LogWriter from the running process) and restore it, how to check and repair a file with verify() and rebuild_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 .wal from a later checkpoint is replayed onto an older .graph without an error, so the restore steps remove any .wal first. A log-writer test runs the documented online backup while another thread writes past the automatic checkpoint threshold.

Known issues

  • Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one WriteTransaction is lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435), and save() 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.

v2.0.5 — 2026-10-11

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.

Fixed

  • A file shorter than one page is refused at open with STG-045 unless 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 is STG-045 and the file is not modified. A zero-length file is still a new database. New code STG-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-007 until the database is reopened or a checkpoint() 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-020 on databases with 1,000,000 or more live facts (#528). The recursive evaluator counted the database's own facts against max_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_results now counts only the facts the rules derive, so raising it with OpenOptions::max_results or :max-results is 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 .graph file (#476). The sentinel was documented to suppress the close-time checkpoint, and Minigraf'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 explicit checkpoint() 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.

Tests

  • Weekly soak test at scale (#392). tests/soak_test.rs grows 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-of and 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.yml uploads weekly, alternating main (Saturday) and v3 (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-of less, since both cost time in the database size on v2.x. soak_short runs the driver at 20K facts in the normal suite.
  • Coverage gates that gate (#396). The per-module gate in coverage-gates.yml had never checked anything: the instrumented test run failed (the lock tests could not find pid_ns_helper), the report it grepped was empty, and every module was skipped with a warning, so the job passed. It also read cargo 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 against coverage-thresholds.toml with scripts/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).

Known issues

  • Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one Wri 10BC8 teTransaction is lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435), save() is not crash-atomic (#374), and a .wal from a later checkpoint is replayed onto an older .graph (#535); all are fixed in v3.0.0

v2.0.4 — 2026-10-08

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.

Fixed

  • BrowserDb no longer drops a dirty page it cannot read from the IndexedDB flush (#470). checkpoint(), importGraph() and every write collected dirty pages with read_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, BrowserDb only. The browser unit tests compile again on wasm32 (a duplicate page_cache_capacity and tempfile-based dir_sync tests).

Tests

  • 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_count and 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.rs opens 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 new golden-corpus job in policy.yml fails a pull request that changes one. The generator is a standalone crate pinned to minigraf = "=2.0.3" (tests/golden/gen/v7/).
  • SIGKILL crash test checks committed data (#384). tests/crash_kill_test.rs used 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 after execute returns; it checkpoints after every transaction, every third, never (WAL replay), automatically every 2 writes, or every third with SyncMode::Normal. After a SIGKILL at 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_SEED replays a failure.

Known issues

  • Same-transaction multi-valued facts can read back as one value (#371, #287), a fact retracted and re-asserted in one WriteTransaction is lost at commit (#477), a later assertion does not replace an earlier valid-time window (#435), and save() 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.

v2.0.3 — 2026-10-06

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.

Fixed

  • 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 reused tx_count values already in the file, :as-of answers 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.

Known issues

  • 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.

v2.0.2 — 2026-09-27

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).

Performance

  • 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 is 0x7F or 0xBF — 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/b when scanning :a). The range now ends at attribute + "\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. The checkpoint() and wal_checkpoint_threshold docs now state that a checkpoint is not a durability boundary: the WAL is already crash-durable.

Fixed

  • 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 .graph file or the <db>.wal sidecar, 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 .graph or 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 with Unbound 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 by link) 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-join failed with INT-031 when no rows reached it (#405). A valid query such as [?item :order-item/product ?p] (or-join [?item] ...) returned or-join variable ?item is not bound in the incoming scope on an empty database, or whenever the clauses before the or-join matched 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-join now returns an empty result when no rows reach it, like or and not-join already did. This applies to queries and rule bodies.
  • A query with $slot bind tokens run through execute() reported an internal error (#407). Such a query failed with [INT-025] internal: unsubstituted :valid-at bind slot reached the executor when it had a :valid-at slot, 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 to prepare(). Prepared queries are unchanged.
  • Bound-entity queries could drop rows when one WriteTransaction wrote the same attribute in several valid-time windows (#323). Both facts share a tx_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))]) reported predicate 'q' is involved in a negative cycle through 'p' on some runs and the reverse on others, because stratification walked the rule dependency graph in HashMap order. It now walks rules in sorted order, so the same rules always give the same message. Which rules are accepted or rejected is unchanged.

Tests

  • 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 (old last_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.

CI

  • New Policy workflow (#395). An MSRV job runs cargo check --all-features and cargo test --lib on Rust 1.89, resolving dependencies to 1.89-compatible versions, and fails if rust-version in Cargo.toml drifts from the tested toolchain. cargo-semver-checks compares the public API with the latest release tag and blocks PRs to main; on major-version branches such as v3 it 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.

Documentation

  • Unsafe WAL recovery advice removed from docs/ERROR_REFERENCE.md. The WAL-001, WAL-002 and STG-011 resolutions said a .wal file 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.rs and src/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.md lists 2.x as the supported line. About 50 broken wiki links in docs/ERROR_REFERENCE.md now 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, corruption or durability stay open until their fix is in a published release (CONTRIBUTING.md). A new known-issue label 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.

Known issues

  • 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.

Notes

  • 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).

v2.0.1 — 2026-09-25

Patch release on the v2.x line. File format is unchanged (v7); no API changes.

Fixed

  • 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, and checkpoint() copied the bad entries forward. The rebuild now reads each fact's real on-disk location (#372).

Known issues

  • 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 single retract) 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.

v2.0.0 — 2026-08-26

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.

Breaking-ish changes

  • Adding OpenOptions::allow_unlocked broke struct-literal construction. At the time this field landed, OpenOptions had all-public fields and was not #[non_exhaustive], so under Rust's semver rules any downstream code building it as OpenOptions { 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 — the Default impl 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 earlier max_derived_facts and max_results fields landed in v0.19.0, before 1.0, so they set no precedent under the stability guarantee in PHILOSOPHY.md §7. Combined with the synchronous field below, it's why OpenOptions is now #[non_exhaustive] — see that entry.

  • OpenOptions gains a second post-1.0 field: synchronous (#302). Same semver consequence as allow_unlocked above — a struct-literal OpenOptions { .. } 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.

  • OpenOptions is 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 via OpenOptions::new()/OpenOptions::default() plus the chainable builder methods instead. Landed alongside the allow_unlocked/synchronous fields specifically so this is the last time a new OpenOptions field forces a major-version bump. A new wal_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 for std::fs::File::try_lock, stabilised in 1.89. Declared as rust-version in Cargo.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. LockFileEx locks are mandatory rather than advisory and exclude every handle but the one holding them, so copying or backing up an open .graph file 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. Set OpenOptions::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 serves flock/OFD locks for a nolock mount out of its own local, in-kernel lock table instead of contacting the server, so try_lock reports an ordinary local success and looks identical to a mount where locking genuinely works — allow_unlocked is never consulted because the code never learns the mount can't really lock. Two separate client hosts writing the same nolock export 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, so nolock NFSv3 exports are an unsupported deployment for multi-writer use — the same category as running mixed Minigraf versions against one file, below. See docs/ERROR_REFERENCE.md STG-027.

  • A database genuinely locked by another process now reports its error later than before — about 375ms later. FileBackend::open_with retries a cross-process WouldBlock up to 10 times with backoff doubling from 5ms and capped at 50ms before concluding another process holds the lock. This exists because fork transiently duplicates the lock-holding descriptor into a child until execve closes its close-on-exec descriptors, so any process that spawns subprocesses — the normal pattern for the Python and Node bindings — could see a spurious WouldBlock from its own child. SQLite solves the same problem with busy_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 .graph file 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 sees is_new and initialises normally — but worth knowing if you inspect the filesystem after a failed open.

  • Minigraf's, WriteTransaction's, and PreparedQuery's public methods now return Result<T, MinigrafError> instead of anyhow::Result<T> (#277). MinigrafError implements std::error::Error (so ? still works against anyhow::Result/Box<dyn Error> callers) and adds .category() -> ErrorCategory and .code() -> &'static str for structured error matching. This lands the foundation for issue #277; every error currently surfaces as the generic INT-000 code until each error category's follow-up PR wires up its real docs/ERROR_REFERENCE.md codes. Every error's Display/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.rs now carries real PRS-0xx codes instead of falling back to INT-000 (#358, #277 2/6). All 79 documented PRS-0xx codes are wired up via bail_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 its Display/to_string() prefix changes to match. parser.rs's internal signatures changed from Result<_, String> to anyhow::Result<_> (crate-internal only — the public db.execute()/db.prepare() surface is unchanged, still Result<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 — the has_over/len() >= 4 conditions 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 their docs/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-0xx code (#359, part of #277's rollout): all 27 documented STG-0xx codes are wired up across src/storage/{mod,persistent_facts,btree,btree_v6,packed_pages}.rs and src/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 the REGISTRY entries the sync test checks against. FileBackend::open_with no longer collapses a more specific header-parse failure (e.g. a bad magic number) into the generic STG-010 — it now propagates a CodedError already present in the chain as-is, only falling back to STG-010 for a genuinely uncoded (e.g. raw I/O) failure. Not covered by a dedicated regression test: STG-011's call site is guarded by a debug_assert! that always fires first in a debug/test build, so it's release-build-only and unreachable under cargo test; the 8 arithmetic-overflow guards (STG-017–STG-024) and STG-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. Two bail! sites with no matching documented STG code ("Header checksum mismatch", reached on a genuine on-disk corruption of an otherwise-valid header, and into_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 real WAL-0xx codes instead of falling back to INT-000 (#360, toward #277): a bad .wal magic number is WAL-001, an unsupported WAL version is WAL-002, a fact whose serialised size exceeds the WAL's per-entry limit (~4080 bytes) is WAL-003, a fact whose serialised size exceeds u32::MAX is WAL-004 (practically unreachable — WAL-003's limit fires first), a WAL header's num_facts exceeding the platform usize is WAL-005 (unreachable on any 64-bit target), and a failure to delete the sidecar .wal file after a successful checkpoint is WAL-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-00N codes (#361, part of #277) instead of the generic INT-000 fallback: QRY-001 invalid entity, QRY-002 attribute must be a keyword, QRY-003 cannot transact a pseudo-attribute, QRY-004 invalid value, QRY-005 transaction failed, QRY-006 retraction failed, QRY-007 unknown predicate, QRY-008 functions lock poisoned, QRY-009 rules lock poisoned — the full QRY category (9 of 9 documented codes; see docs/ERROR_REFERENCE.md). Anything that was matching on MinigrafError::code() == "INT-000" for one of these conditions now sees the specific code instead. QRY-001/004/005/006's ERROR_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 with REGISTRY, 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 into DatalogExecutor::execute_transact/execute_retract, but that code path is not reachable through Minigraf::execute() today — the public write path routes transact/retract through Minigraf::materialize_transaction/ materialize_retraction (src/db.rs) instead, which raise their own uncoded (still INT-000) errors, two of which — "attribute must be a keyword" and "cannot transact a pseudo-attribute" — are already earmarked as API-003/API-004 for the API+INT category PR (#277 step 6).

  • The core binary size budget is raised from 1MB to ~1.2MB (binary-size.yml's SIZE_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-generic bail_coded!/err_coded!/format_template design (no per-call-site monomorphization, unlike the generic build_btree fixed for the same reason in v0.x) — were already in place; cargo bloat shows 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 to INT-000 by 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 real API-0xx codes: API-001 write lock poisoned (execute/begin_write/ checkpoint), API-002 unexpected command variant in the write path (structurally unreachable — kept for defensive completeness, no test possible), API-003/API-004 attribute-must-be-keyword / cannot-transact-a-pseudo-attribute at the materialize_transaction/ materialize_retraction layer (mirroring QRY-002/QRY-003), API-005/ API-006/API-007 db.prepare() rejecting transact/retract/rule, API-008 function registry lock poisoned (register_aggregate/ register_predicate), API-009 WAL not initialized (also structurally unreachable — wal_write_stamped_batch always populates wal two lines above the ok_or_else). Regression tests assert the exact code for every reachable one, including two new lock-poisoning tests that deliberately panic while holding write_lock/functions (via catch_unwind) to exercise API-001/API-008 for real rather than by inspection. db.rs's own "invalid entity"/"invalid value" checks in materialize_transaction/materialize_retraction have no API-0xx counterpart in docs/ERROR_REFERENCE.md (only QRY-001/QRY-004 do, and those cover a different, unreachable-from-db.rs call path — see the QRY entry above) and a WriteTransaction-already-active reentrancy check has no documented code at all, so all three became new INT-0xx codes (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 new INT-0xx codes (INT-004–INT-055; docs/ERROR_REFERENCE.md's old "Appendix: Internal Errors" bullet list is gone, replaced by INT-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 pattern QRY-005/STG-016/WAL-006 already established for "wrap an arbitrary underlying cause" and "one poisoned-lock code per resource": e.g. INT-011 covers all ten "unexpected end of <clause>" parser messages, INT-018 covers all four "variable not bound by any outer/earlier clause" messages, INT-050 covers all thirteen lock-poisoned sites across four files ({} names the specific lock), and INT-048/INT-049 are 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 in evaluator.rs (INT-020) and the runtime aggregate type-mismatch family in functions.rs (INT-041– INT-044) among them — are INT-0xx rather than QRY-0xx purely because this PR's scope is "assign every remaining site an INT code," not "reopen the closed PRS/QRY/STG/WAL category PRs to extend their code ranges"; two existing regression tests in tests/error_codes_qry_test.rs that had locked in INT-000 for 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 in src/query/datalog/evaluator.rs were fixed the same way.

  • The REGISTRY↔docs/ERROR_REFERENCE.md sync test is now a full bidirectional equality check (#362, #277 step 6/6), replacing the subset-only check the foundation PR (#357) introduced. Renamed registry_is_a_subset_of_error_reference_doc → registry_matches_error_reference_doc_bidirectionally: it now also asserts every documented ### CODE section with an "Error text" line has a matching REGISTRY row, 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-#362 ErrorCode variants (PRS×79, QRY×9, STG×27, WAL×6, INT-000×1) plus 9 new API-0xx and 55 new INT-0xx (INT-001–INT-055) = 186 total.

Bug fixes

  • A .graph file is no longer bricked when its holder dies in a container (#317): FileLock recorded the holder's PID in a .graph.lock sidecar 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 holding 1, 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. The pid == our_pid refusal 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_lock on the .graph file itself. The kernel releases it whenever the process exits, however it exits, so a crashed holder leaves nothing behind. Because flock and 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.lock from 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 :alive followed by a stray ? symbol and was rejected as a four-element fact — reporting Optional 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.pest updated to match.

  • The parser no longer silently discards trailing input after a complete form (#305). parse_edn returned successfully as soon as it parsed one complete top-level value, without checking whether any tokens remained — so [1 2 3] garbage ) ] } 12345 parsed as [1 2 3] with everything after it dropped, no error, no indication anything was ignored. parse_edn now 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)" wording transact/retract already use.

Performance

  • BrowserDb no longer allocates a redundant LRU page cache (#275): open_in_memory(), open(), and import_graph() now pass page cache capacity 0 to PersistentFactStorage::new instead of 256. The backing BrowserBufferBackend is already a HashMap-resident, fully-loaded page store, so every PageCache hit was a second RAM-to-RAM copy plus LRU bookkeeping for no benefit.

    This required fixing PageCache itself: capacity 0 previously meant unbounded (the eviction loop's capacity > 0 guard 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_load always reads through to the backend and < 10BC8 code>put_dirty is a no-op. No existing caller passed 0 before this change, so no other behaviour is affected.

  • not/not-join clauses are pushed to the earliest position where their variables are bound (#248): optimizer::plan() now accepts Not/NotJoin alongside Pattern/Expr and 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 after or/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 an or/or-join (not by any pattern in the same clause list) is deferred to the post-filter exactly as before — or/or-join isn'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 the not) shows the effect scaling with the excluded fraction, from ~2% at 0% excluded up to ~55% faster at 100% excluded.

  • or/or-join branches 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 for or-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 plain or, 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_circuit and disjunction/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_entry called File::sync_all() (fsync) after every write, flushing inode metadata the WAL — a pure-append file — never needed alongside the data. It now calls File::sync_data() (fdatasync) instead: same durability guarantee, one fewer metadata flush, for every existing caller, no other change required. A new OpenOptions::synchronous field (SyncMode::Full, the default, matches this fdatasync behavior; SyncMode::Normal skips 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 of synchronous. Separately: begin_write() + N execute() + one commit() 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.

v1.2.3 — 2026-08-10

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.

v1.2.2 — 2026-08-10

Drop-in replacement for v1.2.1. No file-format changes, no public API changes.

Bug fixes

  • Two handles on one file in the same process (#304): FileLock::acquire treated 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 live FileBackends on one file. Each caches its own header.page_count, allocates new pages from that count, and bounds-checks read_page against it, so the two page tables diverge. That — not an allocation off-by-one — is the source of the intermittent Page N out of bounds (total pages: M). It also corrupted the lock itself: whichever handle dropped first ran FileLock::drop and 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 (Minigraf is 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 through to_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 with value_lt / value_cmp, which min, max and :order-by already use. Mixed-type comparison (string vs number) remains an error, and NaN comparisons remain false.

v1.2.1 — 2026-06-28

Drop-in replacement for v1.2.0. No file-format changes, no public API changes.

Bug fixes

  • Magic sets fb adornment: 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 ?mid and keyword entity binding now evaluates correctly (#297).

v1.2.0 — 2026-06-26

Drop-in replacement for v1.1.x. No file-format changes, no public API breaking changes. Upgrading requires no code changes.

Features

  • 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 into execute_query_with_rules
    • Limitation: mutual recursion through negation is not rewritten (documented in ROADMAP §9.6)
  • Per-query complexity limits — :max-derived-facts and :max-results clauses added to query; global default raised to 1,000,000 derived facts (#288, #290)

Bug fixes

  • selective_fact_fetch: include asserted flag 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_count as the B-tree start page instead of the correct offset (#272)

Performance

  • 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 MutexStorageBackend in CommittedFactLoaderImpl to eliminate one Arc::clone per resolve() call (#280, #281)

Infrastructure

  • Split Java, Android, Swift, and C bindings into independent repos under project-minigraf org — completes the #231 follow-up work.
  • minigraf-ffi yanked from crates.io — UniFFI layer inlined into each binding repo's private shim; use the per-language packages instead
  • cascade.yml updated to dispatch core-release to all 7 binding repos on every version tag
  • All binding repos migrated to OIDC trusted publishing — NPM_TOKEN and CARGO_REGISTRY_TOKEN secrets removed; crates.io uses rust-lang/crates-io-auth-action@v1, npm uses setup-node@v6 OIDC
  • Python, Node, and WASM binding repos now include a prepare job that pins the minigraf dependency version in Cargo.toml and commits before building — consistent with Java, Android, Swift, and C

Documentation

  • 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)

v1.1.1 — 2026-05-17

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.

Build

  • Exclude fuzz crate from workspace so cargo-dist can build on Windows (MSVC linker incompatible with #![no_main] libFuzzer targets) — fixes #263

v1.1.0 — 2026-05-17

Drop-in replacement for v1.0.0. No file-format changes, no public API changes, no query surface changes. Upgrading requires no code changes.

Performance

  • 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/or rules (#206, #205)
  • SIMD crossover analysis and benchmarking infrastructure (#229)

Bug fixes

  • Fixed critical correctness and durability bugs found during deep audit (#225): fact visibility edge cases, WAL entry ordering, checkpoint atomicity
  • Fixed read-only handle Drop triggering unnecessary checkpoint, modifying the file on close (#226)
  • QueryResult::Transacted and Retracted reverted to tuple variants — struct-variant form introduced post-1.0 was a breaking change (#261)
  • Added Minigraf::current_tx_count() -> u64 as additive API to expose the :as-of monotonic counter without breaking existing pattern matches

Reliability & testing

  • 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)

Internal

  • 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

Reliability Hardening — 2026-05-17

Summary

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 around StorageBackend) — 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 Err not 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 returns Err, unsupported version returns Err, WAL replay idempotent
  • tests/index_corruption_test.rs — 5 corruption-resilience tests: checksum mismatch triggers index rebuild, btree leaf/internal corrupt pages return Err without 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-temporal
  • tests/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

Tests

935 tests passing (943 total, 8 ignored: 6 or+neg-cycle stratification doc tests, 1 nightly concurrency stress, 1 nightly smoke).


Optimizer & Benchmarks — 2026-05-16

Summary

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 accept Expr (predicate/arithmetic) clauses; they are interleaved at the earliest position where all their variables are bound, minimising intermediate binding sets
  • StratifiedEvaluator gains mixed-rule path: when a rule stratum contains both positive-only rules and rules with not/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 to not/not-join and or/or-join clauses; they are sorted after their ground-variable producers but before unconstrained pattern scans
  • not/not-join blocks placed after the patterns that produce their join variables (was: end of plan regardless); or/or-join blocks 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 using wide::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

Tests

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.


Performance — 2026-05-15

Summary

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_attribute promoted from #[cfg(test)] to production in src/graph/storage.rs
  • New selective_fact_fetch helper in executor.rs: inspects query patterns for bound entity literals and bound attribute strings; calls index-driven fetches instead of get_all_facts() when ≤4 distinct lookups detected
  • as_of queries 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-join bodies pre-computed once into HashSet<Vec<(String, Value)>> keyed on join variables; O(1) probe per outer binding replaces O(N) re-scan. normalize_value handles keyword→Ref representation asymmetry in value position.
  • #203 (executor.rs): or/or-join branches 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-join projects to join_vars before joining.
  • #204 (matcher.rs): join_with_pattern detects join variable (entity position first, value position second), builds HashMap<Value, Vec<Bindings>> once, probes per existing binding O(1). normalize_join_value handles keyword→Ref. Falls back to nested-loop when no join variable found.

Tests

850 tests passing (844 passing + 6 ignored: confirmed or+neg-cycle stratification bug, deferred to post-1.0 backlog).


v1.0.0 — Cross-Platform Release (2026-05-01)

Milestone

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.

Cross-platform summary

All cross-platform targets have shipped:

  • 8.1a — Browser WASM (BrowserDb, IndexedDbBackend, @minigraf/browser on npm) — v0.20.0
  • 8.1b — Server-side WASM (wasm32-wasip1 / WASI, Wasmtime/Wasmer CI) — v0.20.0
  • 8.2 — Mobile bindings (Android .aar on GitHub Packages, iOS .xcframework via SPM, UniFFI) — v0.21.0
  • 8.3a — Python (minigraf on PyPI, pre-built wheels) — v0.22.0
  • 8.3b — Java/JVM (io.github.adityamukho:minigraf-jvm on 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 (minigraf on npm, pre-built .node binaries) — v0.25.0

Also in this release

  • pkg/ renamed to minigraf-wasm/, swift/ renamed to minigraf-swift/ — consistent top-level naming across all workspace packages (issue #179)
  • @minigraf/browser now published to npm on every tagged release (issue #179)
  • @minigraf/wasi published 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/

Tests

795 tests passing (788 passing + 7 ignored: confirmed or+neg-cycle stratification bug, deferred to post-1.0 backlog).

v0.25.0 — 2026-04-26

Added

  • Node.js bindings published to npm as minigraf. Install with npm install minigraf. No build step required — prebuilt .node binaries for Linux x86_64/aarch64, macOS universal2, Windows x86_64. API: new MiniGrafDb(path), MiniGrafDb.inMemory(), .execute(datalog), .checkpoint(). Full TypeScript definitions included.

v0.24.0 — C Bindings (2026-04-26)

Added

  • 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 plus minigraf.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_execute returns a heap-allocated JSON string; call minigraf_string_free to release it.
  • minigraf-c/: new workspace crate (cdylib + staticlib) — Cargo.toml, src/lib.rs
  • minigraf-c/cbindgen.toml: cbindgen 0.29.2 configuration
  • minigraf-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.

v0.23.0 — Java Desktop JVM Bindings (2026-04-25)

Added

  • 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 wrapper
  • minigraf-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.

v0.22.0 — Python Bindings (2026-04-25)

Added

  • Python bindings published to PyPI as minigraf. Install with pip 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.

v0.21.1 — Patch: mobile/WASM docs (2026-04-19)

Changed

  • src/lib.rs: added Feature Flags section and WebAssembly targets subsection to crate-level docs — browser feature, wasm32-unknown-unknown target switcher note, and WASI build command
  • README.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.

v0.21.0 — Android/iOS Mobile Bindings (2026-04-19)

Added

  • minigraf-ffi crate: UniFFI 0.31 bindings exposing MiniGrafDb (open, openInMemory, execute, checkpoint) and MiniGrafError (Parse, Query, Storage, Other) to Kotlin and Swift
  • Android .aar release artifact, published to GitHub Packages (io.github.adityamukho:minigraf-android)
  • iOS .xcframework release artifact, distributed via Swift Package Manager (Package.swift at repo root)
  • mobile.yml CI workflow: cross-compiles Android targets with cargo-ndk, generates Kotlin/Swift UniFFI bindings, assembles AAR with Gradle, assembles xcframework with xcodebuild, and publishes both on every tag
  • docs-check CI job in rust.yml and release.yml — gates releases on cargo doc --all-features passing cleanly

Fixed

  • release.yml: added docs-check to host job's needs and if condition
  • wasm-release.yml / mobile.yml: retry loops extended from 20 to 40 attempts; inputs.tag || github.ref_name ordering corrected
  • minigraf-ffi/android/gradlew: removed inner double-quotes from DEFAULT_JVM_OPTS and replaced xargs/sed eval block with direct exec — fixes "Could not find main class" and garbled usage output
  • minigraf-ffi/android/build.gradle.kts: added android { publishing { singleVariant("release") } } — fixes AGP 8.x "SoftwareComponent 'release' not found"
  • mobile.yml Package.swift commit: pushes to unprotected swift-releases branch and moves tag via gh api -F force=true — avoids branch-protection blocks and string/boolean type mismatch

795 tests.

v0.20.1 — Patch: docs.rs browser module visibility (2026-04-19)

Fixed

  • browser module now appears on docs.rs: added docsrs to the cfg gate and doc(cfg(...)) badge annotation (src/lib.rs)

v0.20.0 — WebAssembly Support (2026-04-18)

Added

  • Browser WASM (wasm32-unknown-unknown + wasm-bindgen):
    • BrowserDb public API: open_in_memory(), execute(), checkpoint(), export_graph(), import_graph()
    • BrowserBufferBackend — in-memory StorageBackend over a flat page buffer, identical byte layout to the native .graph format
    • IndexedDbBackend — page-granular IndexedDB storage (one 4 KB entry per page); only dirty pages written on checkpoint
    • wasm-pack build workflow (wasm32-unknown-unknown --features browser) generating minigraf-wasm/ with JS glue and TypeScript definitions
    • wasm-bindgen-test browser integration tests (Chrome + Firefox via wasm-pack test)
  • Server-side WASM (wasm32-wasip1 / WASI):
    • FileBackend verified 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 tests
    • tests/fixtures/compat.graph — committed v7 binary fixture containing :alice :name "Alice" and :alice :age 30
    • examples/generate_compat_fixture.rs — reproducible fixture generator (native only; no-op on wasm32)
    • native_fixture_readable_by_browser_db wasm-bindgen-test — loads native fixture via BrowserDb::import_graph, verifies both facts
  • Release workflow: WASM artifacts (WASI binary + browser tarball) built and attached on every tag; cargo publish to crates.io on release

795 tests.

v0.19.0 — Publish Preparation (2026-04-08)

Changed (breaking — internal visibility only)

  • Minigraf::repl() factory method replaces direct Repl::new(FactStorage) constructor — users call db.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)

Added

  • Minigraf::repl(&self) -> Repl<'_> — constructs an interactive REPL session; Repl now borrows &Minigraf for lifetime safety
  • Full rustdoc on all public API items with # Examples doctests
  • [package.metadata.docs.rs] in Cargo.toml — docs.rs builds with all-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 warnings step in rust-clippy.yml

Fixed

  • Bare .unwrap() in library code replaced with .expect("lock poisoned") (RwLock operations in cache.rs, evaluator.rs) and .expect("WAL not initialized") (db.rs)
  • FileHeader::to_bytes now takes self by value (clippy wrong_self_convention)
  • Broken intra-doc link [Repl::run] in db.rs fixed to [crate::repl::Repl::run]

788 tests.

v0.18.0 — Prepared Statements (2026-04-04)

Added

  • Minigraf::prepare(query_str) -> Result<PreparedQuery> — parse and plan a query once, returning a PreparedQuery that can be executed many times with different bind values
  • PreparedQuery::execute(bindings: &[(&str, BindValue)]) -> Result<QueryResult> — substitute named $slot tokens and run against the current fact store state; plan is reused on each call
  • BindValue enum — Entity(Uuid), Val(Value), TxCount(u64), Timestamp(i64), AnyValidTime; each variant is permitted only in the appropriate bind-slot position
  • $identifier bind slot tokens in parser — accepted in entity position, value position, :as-of, and :valid-at; attribute position is intentionally rejected at prepare time
  • EdnValue::BindSlot(String), AsOf::Slot(String), ValidAt::Slot(String), Expr::Slot(String) AST variants (parse-only; panic at runtime if unsubstituted)
  • BindValue and PreparedQuery re-exported from lib.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

Internal

  • src/query/datalog/prepared.rs — new module: prepare_query(), substitution logic, 19 unit tests; manual Debug impl for PreparedQuery (avoids FactStorage: Debug bound)
  • Panic guards (no slot-name interpolation) in executor.rs (4 sites) and storage.rs (1 site) for unsubstituted slot variants; CodeQL-safe (no user-controlled string in panic message)

Unchanged

  • db.execute(str) string API — no breaking change
  • Executor, optimizer, matcher — no changes required

v0.17.0 — User-Defined Functions (2026-04-02)

Added

  • Minigraf::register_aggregate(name, init, step, finalise) — register a custom aggregate function usable in both :find grouping and :over (window) clauses
  • Minigraf::register_predicate(name, f) — register a single-argument filter predicate usable in [(name? ?var)] :where clauses
  • FunctionRegistry::register_aggregate_desc / register_predicate_desc (internal API)
  • WindowFunc::Udf(String) and UnaryOp::Udf(String) AST variants for runtime-resolved functions
  • UdfOps, AggImpl, PredicateDesc types in functions.rs

Changed

  • AggregateDesc now uses AggImpl discriminator instead of window_compatible+window_ops
  • apply_expr_clauses now returns Result<Vec<Binding>> and accepts &FunctionRegistry
  • eval_expr accepts Option<&FunctionRegistry> for UDF predicate resolution
  • WindowSpec::func_name() now returns String instead of &'static str
  • Parser emits Udf variants for unknown names instead of erroring (runtime validation)

Test count: 727 tests

v0.16.0 — Window Functions (2026-04-02)

Added

  • Window functions in Datalog :find clause: (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 ?var optional clause: absent means whole result set is one partition
  • :order-by ?var required in every :over clause; :desc optional (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_builtin flag separates built-ins from future UDFs
  • Mixed queries: regular aggregates and window functions may coexist in the same :find clause; aggregates collapse rows first, windows annotate over collapsed rows
  • AggregateDesc, AggState, WindowOps types in functions.rs
  • WindowFunc, Order, WindowSpec, FindSpec::Window types in types.rs
  • tests/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)

Changed

  • FindSpec::Aggregate { func }: type of func changed from AggFunc enum to String; dispatch goes through FunctionRegistry — internal change, no public API impact
  • AggFunc enum removed from types.rs; all aggregate dispatch centralised in functions.rs
  • apply_aggregation and apply_agg_func removed from executor.rs; replaced by apply_post_processing + helpers

Total

707 tests (unit + integration + doc)

v0.15.0 — Temporal Metadata Bindings (2026-04-01)

Added

  • Temporal pseudo-attributes: :db/valid-from, :db/valid-to, :db/tx-count, :db/tx-id, and :db/valid-at are now first-class bindable values in Datalog :where patterns
  • PseudoAttr enum and AttributeSpec wrapper type in types.rs — clean type-safe representation for real vs. pseudo attributes in Pattern
  • parse_query_pattern in parser.rs — detects :db/* keywords in the attribute position; rejects them in entity/value positions (parse error)
  • PatternMatcher::from_slice_with_valid_at constructor — passes query-level valid_at into 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-at binds the effective query timestamp: explicit :valid-at <ts> → Value::Integer(ts), no :valid-at → Value::Integer(now), :any-valid-time → Value::Null
  • :any-valid-time now accepted as a standalone top-level query keyword (previously required :valid-at :any-valid-time form)
  • tests/temporal_metadata_test.rs: 16 new integration tests covering time-interval range queries, time-point lookups, tx-time correlation, :db/valid-at semantics, and all parse/runtime error guards

Total

647 tests (438 unit + 209 integration)

v0.14.0 — Tests + Error Coverage (2026-03-31)

Added

  • 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-sequence
  • tests/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.rs and evaluator.rs
  • cargo-llvm-cov branch coverage command documented in CONTRIBUTING.md
  • CI coverage enforcement: cargo-tarpaulin --fail-under 75 gates every PR; Codecov 75% threshold with 2% drop tolerance; fail_ci_if_error: true
  • Nightly cargo-llvm-cov --branch workflow: uploads LCOV to Codecov (branch-coverage flag) and attaches HTML artifact (30-day retention); also triggerable via workflow_dispatch
  • Codecov badge added to README.md

Coverage

  • 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)

Known Issues

  • or-with-negative-cycle: stratification does not currently detect negative cycles inside or branches. Tracked via #[ignore] in tests/error_handling_test.rs::or_negative_cycle_rejected.

[0.13.1] — 2026-03-27

Performance

  • filter_facts_for_query snapshot fix — function now returns Arc<[Fact]> instead of a throwaway FactStorage, eliminating the O(N) four-BTreeMap index rebuild that occurred on every non-rules query call. execute_query path constructs zero FactStorage objects. execute_query_with_rules still converts Arc<[Fact]> back to FactStorage for StratifiedEvaluator (deferred).
  • ~62–65% speedup on non-rules queries at 10K facts: query/point_entity/10k 22 ms → 8.6 ms; aggregation/count_scale/10k 28 ms → 9.7 ms.
  • Evaluator loop: accumulated_facts computed once per iteration (was 4 separate get_asserted_facts() calls).

Added

  • PatternMatcher::from_slice(Arc<[Fact]>) constructor — creates a matcher from an immutable fact snapshot without index reconstruction.

Technical

  • apply_or_clauses and evaluate_not_join signatures updated to accept Arc<[Fact]> instead of &FactStorage.
  • 6 new tests: 4 in matcher.rs (unit), 2 in executor.rs (unit).

Tests

  • Total: 568 tests passing (390 unit + 172 integration + 6 doc)

[0.13.0] — 2026-03-26

Added

  • 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 including not, not-join, Expr, and nested or/or-join. (and ...) groups multiple clauses into a single branch.
  • match_patterns_seeded on PatternMatcher for seeded branch evaluation.
  • evaluate_branch and apply_or_clauses as pub(crate) helpers in executor.rs.

Technical

  • WhereClause enum gains Or(Vec<Vec<WhereClause>>) and OrJoin { join_vars, branches } variants.
  • DependencyGraph::from_rules refactored with recursive collect_clause_deps helper; Or/OrJoin branches contribute positive dependency edges.
  • Rules with or/or-join in their bodies route to the mixed_rules path in StratifiedEvaluator.

[0.12.0] - 2026-03-25

Added

  • BinOp enum (14 variants: Lt, Gt, Lte, Gte, Eq, Neq, Add, Sub, Mul, Div, StartsWith, EndsWith, Contains, Matches) in types.rs
  • UnaryOp enum (5 variants: StringQ, IntegerQ, FloatQ, BooleanQ, NilQ) in types.rs
  • Expr enum (Var, Lit, BinOp, UnaryOp) — composable expression AST in types.rs
  • WhereClause::Expr { expr: Expr, binding: Option<String> } variant — None = filter, Some(var) = arithmetic binding
  • parse_expr_arg / parse_expr / parse_expr_clause in parser.rs; dispatch at all 4 clause sites (query :where, rule body, not body, not-join body)
  • Parse-time regex validation for matches? patterns via regex-lite; invalid patterns are rejected with a clear error
  • check_expr_safety + check_expr_safety_with_bound in parser.rs — forward-pass safety check; recurses into not/not-join bodies; unbound Expr::Var references are rejected at parse time
  • outer_vars_from_clause updated for WhereClause::Expr — binding variable contributes to scope for subsequent clauses
  • eval_expr, eval_binop, is_truthy, apply_expr_clauses in executor.rs — evaluate expression trees against a binding; type mismatches and div/0 silently drop the row
  • apply_expr_clauses_in_evaluator in evaluator.rs — sibling helper for rule body and not-join evaluation paths
  • not_body_matches in executor.rs updated to seed with outer binding for expr-only not bodies
  • tests/predicate_expr_test.rs — 28 integration tests covering all operators, silent-drop semantics, integer division, NaN, int/float promotion, string predicates, regex, expr in not body, expr in rule body, bi-temporal + expr, arithmetic into aggregate

Semantics

  • Comparison operators (<, >, <=, >=) require both operands to be numeric (Integer or Float); type mismatch → row dropped
  • = / != use structural equality on Value — type mismatch returns false/true, not an error
  • Integer + Float promotes to Float; integer division truncates; division by zero → row dropped; NaN result → row dropped
  • is_truthy: Boolean(true) → true; non-zero Integer or Float → true; everything else (including Keyword, Ref, Null, zero, empty string, Boolean(false), -0.0) → false
  • matches? pattern compiled at eval time via regex-lite; pattern must be a string literal validated at parse time

Tests

  • Added tests/predicate_expr_test.rs (28 integration tests)
  • Total: 527 tests passing (365 unit + 156 integration + 6 doc)

[0.11.0] - 2026-03-25

Added

  • Aggregation in :find clause: count, count-distinct, sum, sum-distinct, min, max
  • :with grouping clause — variables that participate in grouping but are excluded from output rows
  • AggFunc enum and FindSpec enum in src/query/datalog/types.rs; DatalogQuery.find migrated from Vec<String> to Vec<FindSpec>; DatalogQuery.with_vars: Vec<String> field added
  • apply_aggregation post-processing step in executor.rs — runs after binding collection when any aggregate is present
  • extract_variables helper in executor.rs — non-aggregate extraction path (replaces inline loops)
  • apply_agg_func and value_type_name helpers in executor.rs
  • parse_aggregate helper in parser.rs; :find arm extended to accept EdnValue::List (aggregate expressions); :with keyword arm added
  • Parse-time validation: aggregate variables must be bound in :where; :with without any aggregate is rejected
  • tests/aggregation_test.rs — 24 integration tests covering all aggregates, :with, rules, negation, temporal filters

Semantics

  • count/count-distinct with no grouping vars on zero bindings → [[0]] (SQL behavior)
  • All other aggregates on zero bindings → empty result set
  • All aggregates skip Value::Null silently (SQL behavior)
  • Type mismatches (e.g. sum on String) fail fast with a runtime error
  • min/max on mixed Integer/Float is a runtime error
  • :with ?v adds ?v to the grouping key without adding it to output columns

Tests

  • Added tests/aggregation_test.rs (24 integration tests)
  • Total: 461 tests passing (327 unit + 128 integration + 6 doc)

[0.10.0] - 2026-03-24

Added

    < EC2E li>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
  • WhereClause::Not(Vec<WhereClause>) and WhereClause::NotJoin { join_vars, clauses } variants in types.rs; all exhaustive matches updated
  • (not clause…) in :where and 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 in join_vars are fresh/unbound
  • Safety check at parse time: every not body variable must be bound by an outer clause; every join_vars variable in not-join must be bound by an outer clause
  • Nesting constraint: not-join cannot appear inside not or another not-join — rejected at parse time
  • StratifiedEvaluator in evaluator.rs: stratifies rules, runs positive rules first, then applies not/not-join filters per binding for mixed rules
  • evaluate_not_join free function in evaluator.rs: builds partial binding from join_vars, converts Pattern and RuleInvocation body clauses to patterns, runs PatternMatcher; returns true if body is satisfiable (reject outer binding)
  • rule_invocation_to_pattern extracted as pub(super) free function from RecursiveEvaluator
  • Two not-post-filter sites in executor.rs now handle both Not and NotJoin via evaluate_not_join
  • tests/negation_test.rs — 10 integration tests for not: basic absence, multi-clause, rule body, time-travel, negative cycle rejection
  • tests/not_join_test.rs — 14 integration tests for not-join: basic exclusion, multiple join vars, multi-clause body, rule body, :as-of, :valid-at, negative cycle at registration, not+not-join coexistence, RuleInvocation in body end-to-end

Changed

  • Rule.body changed from Vec<EdnValue> to Vec<WhereClause> to support negation clauses alongside patterns
  • executor.rs execute_query_with_rules now delegates to StratifiedEvaluator instead of RecursiveEvaluator directly
  • rules.rs register_rule runs stratify() after each registration; returns Err on negative cycle (rules are not registered on error)

[0.9.0] - 2026-03-23

Added

  • 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), with build_btree for bulk-load and range_scan for leaf-chain traversal
  • OnDiskIndexReader struct + CommittedIndexReader trait — 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 single read_page call on a cache miss; cache-warm pages require no lock, enabling concurrent range scans to proceed in parallel
  • tests/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 roundtrip
  • test_concurrent_range_scans_correctness unit test in btree_v6.rs — verifies all 8 concurrent threads return identical non-empty scan results
  • bench_concurrent_btree_scan Criterion benchmark — measures wall-clock latency at 2/4/8 concurrent EAVT range scans; results updated in BENCHMARKS.md
  • FileHeader v6 (80 bytes): adds fact_page_count u64 field at bytes 72–80; automatic v5→v6 migration on first checkpoint

Changed

  • FORMAT_VERSION bumped 5→6; v5 databases auto-migrated on first save
  • BENCHMARKS.md updated with v6 open/memory improvements, concurrent B+tree scan results, heaptrack v6 numbers, and a "How to read these numbers" methodology section
  • README.md and BENCHMARKS.md: performance table updated to reflect v6 open-time reduction (~2.4×) and peak-heap reduction (~21%)

Fixed

  • Concurrent B+tree range scans no longer serialise on cache-warm pages — 4→8 thread scaling ratio improved from ~2.2× to ~1.9×

[0.8.0] - 2026-03-22

Added

  • BENCHMARKS.md — full Criterion benchmark results at 1K/10K/100K/1M facts with machine spec, HTML report references, and heaptrack memory profiles
  • examples/memory_profile.rs — heaptrack profiling binary; accepts fact count as positional arg
  • Cargo.toml metadata: repository, keywords, categories, readme, documentation fields
  • Memory profile table in README.md "Performance" section

Changed

  • README.md Performance section now links to BENCHMARKS.md for 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)

Removed

  • Dead clap dependency from [dependencies] — clap was listed but never imported in library or binary code

[0.7.1] - 2026-03-22

Fixed

  • Retraction semantics in Datalog queries: filter_facts_for_query Step 2 now computes the net view per (entity, attribute, value) triple via net_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 highest tx_count is 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 the MAX_FACT_BYTES constant (4 080 bytes) exported from packed_pages.rs. Previously, oversized facts could cause a panic deep in the page-packing path.

Added

  • net_asserted_facts(facts: Vec<Fact>) -> Vec<Fact> helper in src/graph/storage.rs: groups facts by EAV triple, keeps the record with the highest tx_count, and discards the triple if that record is a retraction. Used by both executor.rs and storage.rs.
  • check_fact_sizes(facts: &[Fact]) in src/db.rs: validates all facts against MAX_FACT_BYTES and returns a descriptive Err before writing to the WAL.
  • MAX_FACT_BYTES: usize constant in src/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-time with 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_BYTES exact boundary (accepted), MAX_FACT_BYTES + 1 (rejected), in-memory database has no size limit.

[0.7.0] - 2026-03-22

Added

  • 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 capacity
  • CommittedFactReader trait: index-driven fact resolution via page cache (no startup load-all)
  • File format v5: fact_page_format header field; auto-migration from v4 on first open
  • Page-based CRC32 checksum (v5): streams raw committed pages instead of all facts

Changed

  • PersistentFactStorage::new() takes page_cache_capacity: usize as 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_attribute use EAVT/AEVT index range scans

Fixed

  • v4 databases auto-migrated to v5 packed format on first open (no data loss)

[0.6.0] - 2026-03-21

Added

  • Four Datomic-style covering indexes (EAVT, AEVT, AVET, VAET) with bi-temporal keys (valid_from, valid_to in 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)
  • FileHeader v4 (72 bytes): adds eavt_root_page, aevt_root_page, avet_root_page, vaet_root_page (4×8=32 bytes), index_checksum (u32), replacing the reserved field
  • CRC32 sync check on open: index mismatch triggers automatic rebuild
  • FactStorage::replace_indexes() and index_counts() for index lifecycle management
  • Query optimizer (src/query/datalog/optimizer.rs): IndexHint enum, select_index(), plan() with selectivity-based join reordering
  • Join reordering skipped under wasm feature flag
  • Cargo.toml [features] section with default = [] and wasm = []
  • 6 integration tests in tests/index_test.rs for save/reload, bi-temporal, recursive rules regression

Changed

  • FactStorage internal structure: FactData { facts, indexes } under single Arc<RwLock<FactData>> for consistent snapshots
  • PersistentFactStorage::save() writes index B+tree pages and updates header checksum
  • PersistentFactStorage::load() performs sync check and fast-path index load
  • executor::execute_query() now calls optimizer::plan() before pattern matching
  • File format version bumped 3→4; automatic v1/v2/v3→v4 migration on first save
  • FORMAT_VERSION constant updated to 4

Fixed

  • NaN values in Value::Float now canonicalize to a single bit pattern in index encoding (deterministic sort order)

[0.5.0] - 2026-03-21

Added

  • Write-ahead log (WAL): fact-level sidecar <db>.wal with CRC32-protected binary entries
  • WriteTransaction API: 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 .graph and deletes the WAL; auto-checkpoint on configurable threshold
  • FileHeader v3: last_checkpointed_tx_count field (repurposes unused edge_count slot)
  • FactStorage helpers: get_all_facts(), restore_tx_counter(), allocate_tx_count()
  • OpenOptions builder: OpenOptions::new().path("db.graph").open() or Minigraf::in_memory()
  • --file <path> CLI flag for the REPL binary
  • 41 new tests covering WAL, crash recovery, transactions, and checkpoint

Changed

  • src/minigraf.rs replaced by src/db.rs — Minigraf, OpenOptions, WriteTransaction public API
  • File format version bumped 2→3; automatic v1/v2→v3 migration on first checkpoint
  • REPL version string now tracks CARGO_PKG_VERSION automatically

Fixed

  • 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

[0.4.0] - 2026-03-21

Added

  • Bi-temporal support: every fact now carries transaction time (tx_id, tx_count) and valid time (valid_from, valid_to)
  • :as-of N query 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-time to 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

Changed

  • Breaking behaviour: queries without :valid-at now return only currently valid facts (valid_from <= now < valid_to). Existing databases from that era are unaffected because all migrated facts have valid_to = MAX.
  • FactStorage::transact() now accepts an optional TransactOptions parameter

Fixed

  • PersistentFactStorage::load() previously discarded original tx_id when loading facts from disk, making time-travel queries on persisted databases incorrect

[0.3.0] - 2026-03-10

Added

  • 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)

Changed

  • Replaced GQL-inspired syntax with Datalog EDN syntax
  • Data model changed from property graph to EAV triples
  • Query executor rewritten for Datalog pattern matching

[0.2.0] - 2026-02-01

Added

  • Persistent storage backend with .graph file 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

Changed

  • Graph storage now supports persistence

[0.1.0] - 2026-01-15

Added

  • Initial release
  • In-memory property graph implementation
  • Basic graph operations (nodes, edges, properties)
  • Interactive REPL
  • Thread-safe storage with Arc<RwLock<>>
0