Skip to content

Changelog

All notable changes to Grafeo, for future reference (and enjoyment).

[0.6.3] - Unreleased

SPARQL transactions release: SPARQL in a transaction reads one snapshot, conflicting updates fail at commit and graph operations run inside transactions; the plan cache, the per-query memory limit and path searches are bounded. Results that change on upgrade: a statement over the per-query memory limit fails with GRAFEO-S001 in builds with disk spilling, a path search over its budget fails with GRAFEO-S001 instead of GRAFEO-Q006, and a standalone type that a graph type lists keeps applying in the default graph.

Added

  • Config::with_path_search_budget (#492): sets the bytes one variable-length, shortest-path or RDF property path search may hold, never above the memory limit.
  • Config::with_plan_cache_limit (#674): bounds the plan cache in bytes, by a deep estimate of each plan. The default is a sixty-fourth of the memory limit, at least 4 MiB and at most 64 MiB; 0 turns the cache off.
  • validate() names the nodes a listed type would refuse (#764): the warning LISTED_TYPE_ROWS gives, per node type and graph not bound to a graph type that lists it, the count and the first ids of the nodes that break the type, with the steps to make it a type of its own again. Run it after upgrading a file in which 0.6.2 took a standalone type over.

Changed

  • The documentation has a new structure: its pages are grouped in seven sections, and every old link redirects to its new page.
  • A path search over its budget fails with GRAFEO-S001 (#492): it names the path search budget and its setting, in GQL, Cypher and SPARQL; it was GRAFEO-Q006. An RDF property path reads the same budget as the others, no longer narrowed to the memory free when it was planned.
  • The plan cache is bounded by bytes and reported deeply (#674): it held 1,000 plans whatever their size, and memory_usage() counted only its map. Now one byte bound holds parsed and optimized plans, a plan larger than the bound runs uncached, a repeated statement shares the cached plan instead of copying it, and memory_usage().caches reports what the plans hold plus plan_cache_limit_bytes.
  • A standalone type a graph type lists stays in force (#764): a type made with CREATE NODE TYPE or CREATE EDGE TYPE keeps applying by its label in every graph once a graph type lists it, and also in that graph type's graphs, which reverses 0.6.2 for this case; DROP GRAPH TYPE leaves it, DROP NODE TYPE of it is refused while a graph type lists it, and a pattern may name it only as it is. Types a graph type declares itself, and files in which 0.6.2 already took a type over, keep the 0.6.2 rule.
  • Conflicting SPARQL triple updates fail at commit (#718): when two transactions write the same triple in one graph and one of them deletes it, the second to commit fails with GRAFEO-T001 and is rolled back; both committed before, so a delete could silently undo the other's insert. Two inserts of one triple both commit, and a transaction also conflicts with a bulk load (load_rdf_text, load_rdf_into, batch_insert_rdf) that commits while it is open; the load itself is never refused.
  • SPARQL graph operations in a transaction (#718): CREATE, DROP, CLEAR, COPY, MOVE and ADD apply at the commit in order with the transaction's triple updates, and its later statements see them; 0.6.2 refused any SPARQL statement after one with GRAFEO-T004. Graph operations on different graphs, or a graph operation and a triple update in one graph, no longer conflict; two transactions changing one graph still fail with GRAFEO-T001.
  • The per-query memory limit covers every operator that holds rows (#674): Config::with_query_memory_limit now caps what one statement's sorts, groupings, hash and nested-loop joins, distincts, set operations, top-k heaps, correlated subqueries, eager reads and stored procedure results hold together, their strings and lists included, where it covered sorts and groupings only. At the cap a GQL sort spills first; the others fail with GRAFEO-S001 naming the limit, and the statement changes nothing. The limit holds in builds with disk spilling, such as the Python package, grafeo-server and the CLI; the default grafeo crate, edge and WebAssembly builds do not enforce it yet.
  • SPARQL reads at a viewing epoch are refused: with set_viewing_epoch set, a SPARQL query or SHACL validation fails with GRAFEO-Q004 (RDF data keeps no history) instead of reading the present.

Deprecated

  • GrafeoDB::rdf_store(): writes through the RDF store bypass transactions and snapshot reads; use SPARQL updates in a session or transaction, or batch_insert_rdf. Removed in 0.7.0.

Fixed

  • A plan chosen on an empty graph replans after a bulk load (#674): a cached plan is made again once a count the planner reads (nodes, edges, a label's nodes, an edge type's edges) doubles or halves by at least 1,000 rows.
  • A plan built before a schema change is no longer cached after it (#674): a statement planned while an index, constraint, type or graph changed runs once and is planned again next time; a schema change queued in an open transaction no longer drops other sessions' plans, and a rollback keeps them.
  • The default path search budget is at most 256 MiB (#492): a quarter of the memory limit, so a 64 GiB limit let one search hold 16 GiB before it failed.
  • SPARQL reads in a transaction are snapshot isolated (#718): every SPARQL query in a transaction reads the triples and named graphs as they were when the transaction began, with its own updates, at the same point as its GQL and Cypher reads; an auto-commit query reads one committed state for its whole run. It saw triples, graphs and clears that other sessions committed meanwhile.
  • RDF in checkpoints, copies and exports is the epoch's (#718): a checkpoint, save, a copy or export_snapshot that runs while SPARQL updates commit writes the RDF triples and named graphs as of its epoch, with commits going on while it writes. rdf_schema() lists the named graphs (it returned an empty list) and reads at one epoch.
  • The CLI opens every database the default grafeo build writes (#763): cargo install grafeo-cli and the pre-built binaries now have the same features, vector, text and hybrid search, change data capture and graph algorithms included, so a file with a vector or text index opens. A database holding data the build cannot read, such as RDF triples, is refused with the cargo install grafeo-cli --features ... line that adds the feature.
  • SQL/PGQ ranges on one-kind columns use the range scan again: a <, <=, >, >= or BETWEEN on a property whose values are all of the bound's kind reads its index by range and skips chunks by their stored bounds, as in 0.6.1. A column holding values of several kinds still scans and raises 22G04.
  • Concurrent sorts and groupings keep their memory consumers: each spillable sort and aggregate registers under a name of its own and unregisters only that one when it finishes or fails.

[0.6.2] - 2026-11-08

Correctness release: comparisons, nulls and operands of the wrong kind follow ISO GQL and openCypher more closely, a refused permission has its own code, a session can be kept from reading local files, and a graph cleared and written back keeps its size. Results that change on upgrade: a string never equals a number, {k: NULL} in a pattern finds nothing, an operator given a value of the wrong kind raises 22G12 where it answered null, and a file database checkpoints by itself (Changed lists each). Plus join orders from sampled degrees, cycles closed by intersection and lookups that need no index.

Performance

  • Planner: comma patterns pick their join order from sampled degrees, cycles close by lookup or intersection, and same-type chains up to 6 hops count without listing paths. Common queries stay at their speed; the first plan of a comma pattern costs a little more.
  • Pinned far nodes and IN: a hop to a pinned node starts at that end when it costs less, a key on several nodes reads the shorter side, and a long constant IN list is a set.
  • Lookups with no index: an equality on a property reads its column directly, a LIMIT stops early, and reopened columns skip chunks by their stored bounds.
  • Typed graphs: a read no longer pays for a graph type's checks, which only a write needs.
  • Constraints: UNIQUE and NODE KEY build an index on their first property, so an insert under one no longer reads the whole label.
  • Weighted algorithms: Dijkstra, SSSP, A*, Bellman-Ford, Floyd-Warshall, Kruskal, Prim, max_flow and min_cost_max_flow read each edge's weight directly instead of loading the whole edge; Dijkstra takes about a third fewer instructions.
  • Smaller default builds: the wal feature no longer pulls in tokio; only async-storage and server do.

Changed

Queries that ran in 0.6.1 and now fail or behave differently.

  • CREATE INDEX holds its label only: writes on other labels no longer pay for it. A query on another label scans, with the same rows and a note in EXPLAIN; a query with no label reads the label indexes and scans only the rows they do not cover. Existing indexes stay as they are, and create_property_index still covers every label. A 0.6.1 release opens such a file with the same rows, without those indexes, and a checkpoint there drops them.
  • Closed graph types: REMOVE fails with G2000 when it would take a node's last label or the label that declares a property it holds, and so does removing a label an edge type's endpoints need, wherever that rule applies. import_tsv, import_tsv_str and import_mmio fail the same way when the graph's rules refuse _Imported nodes or the edge, as does dropping or replacing a property that nodes or edges still hold. DROP NODE TYPE and DROP EDGE TYPE are refused while a closed graph type bound to a graph lists the type. Files already holding such data still open.
  • Writes: |, !, % and parentheses in the labels of an INSERT or a single-node MERGE are an error naming the statement, as they name no labels.
  • Rules: a rule added over data that breaks it fails with GRAFEO-V001, and so does a commit whose writes break a rule added since the transaction began.
  • Graph types: a type a graph type lists applies only in graphs bound to it, so its rules and defaults no longer reach other graphs; validate() reports affected types, and Python warns at open. Dropping a graph type drops the types only it listed, and a second, different declaration of a type is refused. See the upgrade note.
  • Transactions: START TRANSACTION, COMMIT and ROLLBACK on a per-call execute are refused, CREATE GRAPH ... AS COPY OF is refused in a transaction, and no SPARQL statement runs after a SPARQL graph operation until the commit.
  • A string and a number are never equal: '5' = 5 is false and '5' <> 5 true, in GQL and Cypher, also in indexes, joins, IN, MERGE and upserts; id(n) = '5' finds nothing. In GQL, a node compared with = to another kind of value is false instead of an error.
  • Lists and records containing a null can compare as unknown: [null] = [null] and [1] = [null] drop the row in a WHERE, while a definite mismatch such as [1, 2] = [null, 3] is false. A value containing a null matches nothing in a join or a lookup. DISTINCT, grouping and UNIQUE still treat nulls as one value.
  • Comparing values of two kinds with <, <=, > and >=: GQL orders them as ORDER BY does (a string before every number, a boolean before every number), with or without an index; Cypher and Gremlin answer null; SQL/PGQ raises 22G04, as do its =, <> and IN, whichever plan reads them (an index or key-column read returned rows where the scan raised).
  • Wrong kinds: an operator or list index given an operand of a kind it does not take (true + 1, -'a', [1, 2, 3][1.5]) raises invalid value type (22G12) instead of answering null; a sign on a non-number reports 22G12 rather than 22G03. A null operand still gives null.
  • + of a string and another kind in GQL raises invalid value type (22G12) in either order, for any non-null value that is not a string ('x' + 3 gave 'x3', 'is:' + true gave 'is:true').
  • Procedure arguments: a value outside what an argument allows raises invalid parameter value (22023), in CALL (the load procedures included) and in the Python db.algorithms methods; grafeo.pagerank(2.0), a negative max_iterations, a NaN tolerance or a negative k ran with meaningless results or clamped values (in Python, pagerank(damping=2.0) returned scores like 39,602,142,754). A missing required argument, or null for it, raises 22023 too, naming the argument and its type (it raised 22000). In the Python algorithm methods, None for an optional argument means its default, and None for a required one raises GrafeoError 22023 instead of TypeError. The load procedures take their arguments in the map or the positional form (path, then labels, or type, source and target), raise 22023 for a delimiter that is not one character, an unknown column type, a wrong key in source or target (an unknown key was ignored) or a projection (it was ignored), and 22G03 for an argument of the wrong type.
  • Edge weights: weighted algorithms refuse an edge value they cannot use and name the edge and property: a non-number raises 22G12 (it read as 1.0); NaN, an infinity, or a negative weight in Dijkstra, A*, SSSP or a flow capacity raises 22023.
  • Procedure timeouts: graph algorithm, search, schema and CSV load procedures, and the Python algorithm methods, stop at the query timeout with the usual timeout error, never a partial answer; a run used to hold its thread until it ended.
  • grafeo.max_flow and grafeo.min_cost_max_flow rows: one row per edge that carries flow, so parallel edges each get a row; they gave one row per node pair.
  • grafeo.topological_sort on a graph with a cycle raises a data exception (22000) naming one cycle, such as 3 -> 5 -> 8 -> 3; it returned a row in an undocumented error column. The Python method still returns None for a cycle.
  • Minimum spanning trees of equal weight: grafeo.kruskal and grafeo.prim return the one with the lowest edge ids (it depended on the order edges were read).
  • A WAL with an empty segment, which a crash in 0.6.1 could leave while starting one, is refused at open with an error naming wal_salvage: reopen it once with wal_salvage, which sets the empty file aside and loses no commit (#725).
  • Incremental backups that cannot continue their chain fail with GRAFEO-S005, naming the cause: no backup cursor, a chain behind the cursor, or log that was removed. Some of these raised an invalid-input error. A new full backup starts a new chain (#673).
  • Automatic checkpoints are on: a file database checkpoints by itself once its WAL holds 256 MiB past the last checkpoint, on a background thread that starts when it first has work; turn it off with checkpoint_wal_size=None (Python), checkpointWalSize: null (Node.js) or Config::without_checkpoint_wal_size(). The WAL's segment size is a setting (wal_segment_size, 1 MiB to 1 GiB, 64 MiB by default), and a checkpoint size below 1 MiB is refused.
  • The default memory limit follows the process's real ceiling (#751): 75% of the smaller of the machine's memory and any cgroup (Linux, at the process's cgroup or a parent) or job-object (Windows) limit. Windows and macOS now read their memory instead of assuming 1 GiB; WASM keeps 768 MiB.
  • Config::with_memory_limit is enforced (#751): every write that grows the store (a node, edge or value write in GQL, Cypher, the direct API, a bulk load or a CALL, an RDF triple in each form, a property, text or vector index build) is granted against the limit, with an eviction pass that spills what it can first, and one that passes it is refused with GRAFEO-S001; reads that fit their query-memory budget still run. A database file above its limit opens with a warning (MEMORY_LIMIT_PASSED) and replays regardless of it, so its first write is refused. Under pressure a file database spills its older row groups' indexed embedding columns and keeps the newest's; a write into a spilled column needs no room for it.
  • A per-query memory limit, Config::with_query_memory_limit (#751): it caps what one statement's operators hold, defaults to the memory limit and never exceeds it; a sort or grouping past it spills first and, where it cannot spill, fails with GRAFEO-S001 naming the limit and changes nothing. memory_usage() reports it as query_limit_bytes, in every binding.
  • Config::with_memory_fraction applies per open: it is taken of the memory detected when the database opens, and a database file no longer stores its bytes; with_memory_limit is stored as before.
  • file: URLs follow RFC 8089: in LOAD CSV, LOAD DATA and the load procedures, file:///srv/data.csv and file://localhost/srv/data.csv name the absolute path /srv/data.csv (file:/// was stripped and the rest read as a relative path), and a file: URL with another host is refused.
  • Permission refusals have their own codes: GQLSTATUS 42501, with GRAFEO-Q007 for a write, DDL or writing procedure that the session's role or graph grants refuse and GRAFEO-Q008 for a statement that reaches outside the database on a session without external access; both were GRAFEO-Q002 with 42000. Python raises GrafeoPermissionError, a subclass of GrafeoError.
  • memory_usage() reports what the store holds: its store, index, version and string-pool figures are the counted bytes, kept as each write grows them, instead of estimates.
  • A negative SKIP or LIMIT after a GQL CALL raises 22G02, as in every other RETURN; 0.6.1 ignored the count.
  • TierOverride::ForceDisk is refused on sections that cannot spill: only VectorStore can move to disk, so ForceDisk on another section (TextIndex, PropertyIndex, LpgStore, RdfRing, CompactStore and the rest) now fails Config::validate, where it was accepted and did nothing.
  • Temporal literal cleanup: a LOCAL DATETIME property refuses DATETIME '...' with an offset, and a schema error names the value as the query writes it (DATETIME '...', offset kept).

Added

  • Graph procedures: CALL grafeo.ktruss() and grafeo.ktruss(k), grafeo.total_triangles() and grafeo.subgraph_isomorphism(pattern_edges, pattern_nodes), and the Python methods ktruss(k=None) and subgraph_isomorphism_count(pattern_edges, pattern_nodes).
  • Query warnings: a statement that uses a deprecated form or has something worth knowing returns its rows with warnings, each with a GQLSTATUS code (class 01), a kind and a message. They are on QueryResult::warnings and on streaming results, in every binding (Python also raises them through warnings.warn), and the CLI prints them on stderr. Experimental.
  • SQL/PGQ || concatenates strings and lists.
  • Procedures in every build: CALL grafeo.procedures() and the CSV load procedures are listed in every build, with each argument's type, default and allowed values where it declares them. A call of a procedure this build leaves out fails with GRAFEO-Q004, naming the feature that brings it, instead of as an unknown name.
  • Sessions without external access: Identity::with_external_access(false) refuses every statement that reads or writes outside the database, such as the CSV loads, before it runs.
  • The memory limit and spill settings at open, from every binding (#751): C (GrafeoOptions with grafeo_open_with_options), Node.js, Python, Go, C# and Dart take the settings Config takes, and WASM takes DatabaseOptions.memoryLimit and historyRetention; memory_usage() reads them back in each binding (C reports them and the open's warnings as JSON, MEMORY_LIMIT_PASSED among them), an open past the limit warns and still reads, and a snapshot import takes them too (Database.importSnapshot(data, options) in WASM, GrafeoDB::import_snapshot_with_config in Rust).
  • Sessions in the bindings: db.session(read_only=..., external_access=...) in Python, db.session({ readOnly, externalAccess }) in Node.js and grafeo_session_open in C run queries with those limits; external_access=False refuses every statement that reads or writes outside the database.
  • The procedure listing says what each procedure does: CALL grafeo.procedures() lists whether it writes the graph (writes, a BOOLEAN) and what it reaches outside the database (external_access: 'none', 'reads' or 'writes').
  • grafeo.pagerank warns (01Y01) when it runs out of iterations before its scores settle within tolerance.
  • WAL retention limit: after a backup, checkpoints keep the WAL the next incremental backup needs, up to Config::with_wal_retention_limit (1 GiB by default; Python wal_retention_limit, Node walRetentionLimit). Past the limit the oldest log is removed with a warning, and the next incremental backup fails with GRAFEO-S005. release_backup_cursor() (Rust, Python, Node, C, C#) stops keeping log for backups (#673).

Deprecated

  • ZONED DATETIME '...' and ZONED TIME '...' literals: write DATETIME '...' or TIME '...' with an offset. They warn with 01X01 and 01X03, and 0.7.0 refuses them.
  • + on two lists in GQL and SQL/PGQ: use ||. It warns with 01X02, and 0.7.0 refuses it.

Fixed

  • Label expressions on a path's first node: IS, &, |, !, % and parentheses were ignored there, so MATCH (x IS A)-[:R]->(t) also matched nodes without A, and a SET, REMOVE or DELETE after it changed them. All GQL and SQL/PGQ path patterns now check them (#736).
  • Label expressions in writes: INSERT (n IS Person), INSERT (n:A&B) and MERGE (n IS Person) created a node with no labels; IS A and A & B now set those labels. Databases written before this may hold such nodes, and nodes changed or deleted by the query above, which no fix can recover: find unlabelled nodes with MATCH (n) WHERE size(labels(n)) = 0 RETURN n.
  • UNIQUE refuses 0.0 and -0.0 as duplicates whether or not the property has an index.
  • UNIQUE, NODE KEY and MERGE treat 1, 1.0e0 and the decimal 1.0, and [1] and [1.0], as one value, and UNIQUE and NODE KEY count every NaN, of either sign, as one value.
  • SPARQL joins: fixed a slowdown in multi-way joins from 0.6.1.
  • SPARQL parsing: a . after a FILTER, OPTIONAL, MINUS, GRAPH or nested group parses again (#737).
  • SPARQL subqueries and paths: a sub-select inside GRAPH ?g binds ?g (#739), BIND(EXISTS {...} AS ?v) evaluates (#740), and EXISTS and NOT EXISTS read each row's values, as the SPARQL 1.2 draft says. ?s :p? :o with a constant object returns :o and the subjects that reach it, each once (it returned every subject of :p, repeated), a subquery with an aggregate gives all its rows, and nested groups keep their rows.
  • SPARQL graphs: inside GRAPH ?g { ... }, ?g is bound only after the group, as SPARQL says: a FILTER on ?g, ?g used as a term, and a nested GRAPH ?g inside EXISTS, NOT EXISTS, OPTIONAL or MINUS no longer see it bound early, which gave missing or extra rows. GRAPH <iri> over a graph the dataset does not hold gives no row (#738), FROM over two graphs returns a shared triple once, and FROM without FROM NAMED exposes no named graphs. A variable repeated within one triple pattern holds the same term in each position (?x :r ?x, ?g ?p ?g inside GRAPH ?g, any pairing of subject, predicate, object and graph, also inside OPTIONAL); every matching triple was returned, and COUNT(*) counted them all.
  • Booleans: <, <=, > and >= order false below true in every plan; they could return null and lose rows.
  • Comparing lists: <, <=, > and >= on two lists or two paths compare element by element in GQL and Cypher; [1] < [1] is false (it was null).
  • Lists: GQL || concatenates lists, and Cypher + appends or prepends a single value to a list, a null included ([1, 2] + 3, [1, 2] + null); both gave null before.
  • + with a string in Cypher: a number and a string join in either order (1 + 'a' is '1a', written as toString() writes it; it was null).
  • Grouping by a parameter: a grouping key built from a parameter (RETURN $p + 1 AS x, count(*), $m.k, $list[0], size($list)) no longer fails with an undefined variable.
  • Schema statements roll back with their transaction, also across a crash (#727).
  • Conditions that can fail, such as 100 / n.age > 0, no longer fail on rows a label or an earlier condition rejects.
  • Graph type patterns: naming an existing type no longer wipes its properties, and several patterns of one edge type no longer lose all but the last.
  • Closed graph types in a schema: a node or edge type added with ALTER GRAPH TYPE ... ADD is held like one listed by CREATE GRAPH TYPE, so DROP NODE TYPE and DROP EDGE TYPE refuse it while listed, and ALTER GRAPH TYPE ... DROP takes it out, in files written by 0.6.1 too.
  • Backups: a restore checks the full backup's checksum, as it does an incremental backup's, and refuses a damaged file by name before it writes anything (#744); incremental backups and restores of a large WAL stream the log a segment and a frame at a time instead of reading it into memory (#673).
  • A null optional procedure argument, however computed, takes the parameter's default instead of failing.
  • grafeo.min_cost_max_flow gives each parallel edge its own capacity and cost (two edges costing 1 and 5 reported 50 where 30 is right), and refuses a negative-cost cycle the source reaches (22023), where the call never returned.
  • grafeo.kruskal and grafeo.prim take the lightest of the edges between two nodes, in either direction, and grafeo.prim from a start node returns the whole tree; Kruskal kept the first edge it met and Prim missed a lighter edge pointing back, both reporting a heavier tree, and Prim followed only outgoing edges.
  • grafeo.topological_sort gives the same order on every run (the lexicographically smallest by node id).
  • GQL statements after CALL ... YIELD: a statement that starts with a procedure call goes on with any clause, MATCH, OPTIONAL MATCH, FILTER, LET, FOR, ORDER BY, another CALL, SET or INSERT, as ISO GQL allows (14.3); only RETURN was accepted, and a following MATCH was a syntax error.
  • GQL NEXT then CALL: a procedure call after NEXT kept the earlier statement's columns, grouping the result by them; it now returns only its own columns, and the incoming fields stay in scope.
  • SKIP and LIMIT after a GQL CALL: SKIP $n and LIMIT $n in the RETURN after a procedure call take effect; they were ignored.
  • HAVING and FINISH after a GQL CALL are accepted; they were a syntax error.
  • SQL/PGQ CALL ... LIMIT $n takes its limit; it was ignored.
  • Stored procedures run in builds without the graph algorithms (the lpg and edge profiles, the CLI): CREATE PROCEDURE succeeded there and its CALL said the procedure was unknown.
  • A null in a property map (MATCH (n {k: NULL}), also on an edge, in GQL, Cypher and SQL/PGQ) finds nothing, as an equality with null does; it found the elements without the key. A null in a MERGE pattern still matches an absent property.
  • A DATETIME literal keeps its offset: DATETIME '2024-01-01T10:00:00+01:00' is a ZONED DATETIME at +01:00 and Z is +00:00, as the ZONED DATETIME '...' deprecation names it; 0.6.1 gave the UTC time without the offset. Without an offset it stays a local datetime.
  • SQL/PGQ EXPLAIN returns the plan; it ran the statement and returned its rows, in Python's explain_sql too.
  • INTERSECT and EXCEPT inside CALL { }: the columns they return are visible after the call, as with UNION and OTHERWISE; reading them raised "Undefined variable".
  • A MERGE after an OTHERWISE body in CALL { } uses the node the body returns; it raised "Variable already declared outside the CALL subquery".
  • YIELD: a column a procedure does not have is refused when the call is planned, naming its columns; it failed only after the algorithm had run (4.5 s on 4,000 nodes).
  • WAL segments: a panic while the WAL started a new segment no longer makes every later checkpoint fail until a reopen (#725).
  • Checkpoints check free disk space first and fail with a clear error naming the space needed and available, changing nothing; a checkpoint that fails partway gives back the disk space it took (#726).
  • Go and C# bindings: a database, transaction, graph, stream or point-in-time view could be freed by the garbage collector while a call on it was still running, which could crash the process (for example on the last Next of a stream in Go); every native call now keeps its handle alive. In C#, an async call on a database disposed meanwhile throws ObjectDisposedException.
  • WASM importRdf is in every build with the RDF store, the full package (@grafeo-db/wasm) included; it was missing there.
  • Vector search with a filter on a quantized index could return fewer than k results, or none, when the allowed nodes lay far from the query; it now searches among the allowed nodes.
  • Opening and closing without writes leaves the file unchanged: close() checkpoints only when something changed since the last checkpoint, so a database opened only for reads keeps its file byte for byte (its header was rewritten on every close).
  • The C header declares the backup functions: grafeo_backup_full, grafeo_backup_incremental, grafeo_restore_to_epoch and grafeo_clear_plan_cache, exported since earlier releases, are now declared in grafeo.h.
  • A crashed query's spill files are removed at open: the next read-write open deletes the <file>.spill/query_* directories a crash left, with the vector cache, before it measures the spill volume.
  • A graph cleared and written back keeps its size: the slots of deleted rows (edge ends, property values, adjacency offsets) go back once a whole vector of 2,048 rows below the live ones is gone, in a session and across reopens. In 0.6.1 each clear and rewrite of 2,400 nodes and 4,800 edges added about 500 KB within a session.

Security

  • Queries can be kept from reading local files: a session without external access refuses LOAD CSV, LOAD DATA, the CSV load procedures and SPARQL LOAD before any file is opened; through a server, give remote sessions none.

Contributors

  • Thanks to atontini for correcting the read-only docs and pinning the lock behind them (#734, #735).

[0.6.1] - 2026-11-01

Correctness and speed release. Backups and restores survive a crash, stored procedure bodies run as their statements do, SPARQL follows SPARQL 1.1 in OPTIONAL, MINUS, BIND and FILTER EXISTS, GQL quantified patterns repeat as ISO GQL defines them, the query timeout stops every query, and dates, times, bytes and NaN keep their type through every binding. Plus a CSV and an RDF file loader, schema() with value types, and faster SPARQL lookups, Cypher multi-hop patterns, degree counts and bulk updates by key.

Changed

Queries and calls that ran in 0.6.0 and now fail or return another type (each fix is under Fixed). Results that change because a wrong answer was fixed are under Fixed only; every one in the differential test corpus is listed, with its reason, in scripts/difftest/reviewed/0.6.1.txt.

  • Casts: CAST of text that is no number fails with 22018, to a DECIMAL(p, s) that is too small with 22003, and a float outside the integer range, an infinity or NaN to an integer with 22003; they gave null, the largest integer or 0. toInteger and toFloat of text that is no number still give null.
  • GQL patterns: a quantified pattern of more than one edge without an upper bound or over 16 repetitions fails, an unbounded walk around a cycle fails at the path budget instead of stopping at 100 hops, and {3,1}, *3..1 or a quantifier both inside and after an edge's brackets is a syntax error.
  • SPARQL: an undeclared prefix other than rdf, rdfs, xsd, owl and fn, a variable or quoted triple in INSERT DATA or DELETE DATA, and BIND to a variable in scope are syntax errors; N-Triples, INSERT DATA and batch_insert_rdf refuse a literal subject or a predicate that is not an IRI.
  • Indexes: CREATE INDEX with a name another index has fails (IF NOT EXISTS keeps the existing one). A file with a named vector index cannot be opened by 0.6.0.
  • Transactions: SERIALIZABLE is refused with GRAFEO-Q004 (it ran as snapshot isolation); START TRANSACTION, COMMIT and ROLLBACK through a transaction object's execute fail with 25000; in C#, Rollback() after a commit throws.
  • Files and backups: a file already open fails with GRAFEO-S004 (08000) and a restore without its manifest, cursor or full backup with GRAFEO-V001; an incremental backup into another directory, a snapshot whose catalog does not apply and a parameter nested over 128 levels are refused; hybrid_search without an index it can read fails.
  • Calls and config: a procedure call with an unknown map key or an extra positional argument fails naming the parameters; TierOverride::ForceDisk for a section that cannot move to disk is refused by Config::validate.
  • Bindings: Python node["k"] and node.get("k") return native values. In Go, C# and Dart a DATE, TIME, DURATION or ZONED DATETIME result is a typed value, and in Node.js its tag object ({ $time: '12:30:00+02:00' }); they were strings or maps. A C# DateTimeOffset parameter is a ZONED DATETIME and a Go time.Duration a DURATION.

WASM: faster (and larger)

  • Built for speed: queries run about 1.6 times and inserts about 4.8 times as fast as in 0.6.0; @grafeo-db/wasm-lite grows from about 940 KB to 1.1 MB gzipped and @grafeo-db/wasm from 1.6 to 1.7 MB.
  • The full package uses the lightweight regex engine, as lite does, about 260 KB smaller; =~ and SPARQL REGEX run about as fast as in 0.6.0.

Added

  • schema() describes the data's shape: each label and edge type with its properties and their value types, and each edge type with the labels it connects, in Rust and every binding.
  • A native CSV loader: load_nodes_csv and load_edges_csv (Python, Rust, sessions) and the grafeo.load_nodes_csv and grafeo.load_edges_csv procedures load a typed CSV file in one transaction, checking its keys, about 2.7 times as fast as batch inserts. Experimental.
  • Loading RDF files and text: load_rdf(path) and load_rdf_text(text, format) in Python and Rust load Turtle or N-Triples into the default or a named graph as one bulk write, about three times as fast as a batch insert in 0.6.0.
  • Query timeouts in Python and Node.js: query_timeout and execute(..., timeout=) in Python, queryTimeout in Node.js.
  • Errors say what to do in every binding: Python raises GrafeoLockedError for a database open elsewhere, Node.js errors carry errorCode, gqlStatus and isRetryable, WASM errors their GQLSTATUS, and a Node.js Transaction rolls back when disposed.
  • SQL/PGQ graph patterns are GQL's: label expressions, an element's own WHERE, quantifiers, ANY SHORTEST and ALL SHORTEST, path modes and path variables with PATH_LENGTH, NODES and EDGES inside GRAPH_TABLE. A shortest path over more than one edge pattern is not supported yet.
  • SPARQL looks triples up from bound terms: a pattern, an OPTIONAL, an alternative path or a UNION on a bound subject or object finds its triples through the indexes, a group starts at its most selective pattern, and a property path from bound terms searches from them. LDBC IS1 on RDF runs about 7 times and IS4 about 24 times as fast, IC13 in under a millisecond (it took 6 s), and ?s (v:KNOWS|^v:KNOWS) ?f from a bound ?s in about 0.04 ms (it took 80 ms).
  • SPARQL keeps its statistics across small writes: a query after a write or a load no longer reads the whole store (an INSERT DATA and a query: 1.4 ms, was 178 ms), and the RDF store's indexes are keyed by term ids, for 27 to 31% less memory (#659).
  • Faster queries: multi-hop patterns stream (LIMIT 10 over two hops in under a millisecond and no memory, was 0.4 s and 440 MB), Cypher patterns over several relationships are 2 to 4 times as fast, degree counts read the adjacency (about 3.5 times as fast grouped, 2 to 2.8 times without grouping), property reads, filters and grouping 1.1 to 1.3 times, and a shortest path between two nodes stops sooner (LDBC IC13 about 0.25 ms per pair, was 6.3).

Deriva

Changes for Deriva, which keeps its graph in an embedded Grafeo database through the Python binding.

  • Bulk updates and reads: update_nodes(labels, rows, key="id") updates existing nodes by key in one call (about 6 ms for 1,500 rows, where the same query took 500 ms; through an index on the key, 0.25 ms for 40 rows), and get_properties_batch(ids, keys) reads several properties of many nodes, also in Rust.
  • grafeo[arrow]: the extra that installs pyarrow for nodes_to_arrow(), edges_to_arrow() and to_arrow().
  • Updates and merges by key without an index join once: UNWIND $rows AS r MATCH (n:Method {id: r.id}) SET ... is about 60 times as fast, and MERGE (with upsert_nodes) of 1,500 rows takes about 20 ms (it took 5 s).
  • Faster label queries: grouping by labels(n) about 27 times as fast, count(*) of a label from the store's count, an OR of labels reads only those labels (about 2.5 times), an edge check of one type stops at the first edge (about 200 times on busy nodes), an index lookup looks past label checks (about 45 times), and a lookup by an indexed key on two labels checks both in the lookup.
  • EXISTS runs once over the rows it checks, also inside OR and NOT (about 3 times as fast), and an OPTIONAL MATCH from bound nodes reads only their edges (LDBC IC13 under a millisecond, where it took over 30 s).
  • Whole nodes from Python queries cost less: about 1.4 times get_nodes_by_label, was 1.8 times.

Fixed

Every query result in the differential test corpus that differs from 0.6.0 is listed, with its reason, in scripts/difftest/reviewed/0.6.1.txt.

  • Backups and restores survive a crash (#667, #677): a restore is on disk before it returns, so commits after it no longer replay onto the old data, and a failed one can be retried; a backup is durable before the log it covers is let go; backups run one at a time; an incremental backup with nothing new succeeds, and a broken chain fails with the new GRAFEO-S005.
  • A restore replaces the catalog, and a restore that fails changes nothing.
  • Transactions (#670): a failed commit ends the transaction in every binding; rolled-back writes no longer block others; a direct call that fails partway leaves nothing; checkpoints and copies keep raw store writes; time travel never sees a commit in progress, and inside a transaction checks the retained history.
  • A write above a join reads the data as the statement started (#677), so it no longer changes a row twice or matches what it just created.
  • Vector and text indexes, searches and edge-list imports follow the selected graph (#677).
  • Migrating a 0.5.x file keeps its indexes, and compares the new file with the old database before replacing anything.
  • No more aborts: PROFILE of a count over a multi-hop pattern and of a SPARQL DELETE WHERE without variables, a SPARQL syntax error that looped until memory ran out, a parameter nested over 128 levels, thousands of conditions on a path or Gremlin branches (#668), a two-hop pattern through a node with over 65,536 paths, and values that wrapped around (the largest id, a WAL record over 4 GiB, an RDF ring over 2^32 terms).
  • GQL quantified patterns (#678): a WHERE inside holds for each step, a segment of several edges repeats within its bounds, an unbounded walk is no longer cut at 100 hops, {,2} starts at 0, and (n:%) matches only labeled nodes.
  • OPTIONAL MATCH and EXISTS: an OPTIONAL MATCH keeps its rows when its WHERE holds a subquery, and an EXISTS with a condition no longer runs over nodes the query never asked about.
  • Procedures (#678): OPTIONAL CALL keeps the row with nulls; a stored procedure returns as many values as it declares, and a YIELD in another order picks by name; procedure columns follow the arguments; a body runs with the schema and constraint checks of its statements, USE GRAPH with its grant checks, and a limit of 16 nested calls.
  • Expressions: aggregates inside other expressions and LET variables inside lists, maps and CASE (#539), several LET definitions in order, negative list slices, IS TYPED LIST<NODE>, size() and toList() of a vector (#585), all_different and same by value, Infinity in text, a float % by zero is NaN, and Cypher sum() of no values is 0.
  • Numbers: toInteger(' 42 ') is 42 and toInteger('4.2') 4, a float to an integer no longer wraps around, and decimal sum and avg no longer depend on the order of the rows, nor do products and remainders that fit fail (#669).
  • Ordering and grouping: a TIMESTAMP and a ZONED DATETIME of one instant order and group together in GQL and SQL/PGQ, GROUP BY without ORDER BY gives its groups in first-row order on every run and every database, also when they spill, and aggregates over a chain of edges read the variable they name.
  • Syntax: GQL // comments, non-reserved words and double-quoted names as names, a later INSERT creating its new nodes, doubled backticks in Cypher names (#579), and compound types in Cypher DDL (#678).
  • Cypher and Gremlin: shortestPath from a node to itself is no path, a variable-length relationship whose upper bound is below its lower bound (*3..1) matches nothing, as in openCypher, undirected variable-length counts no longer fail on the path budget, and Gremlin coalesce() and optional() choose a branch per traverser (#668).
  • SQL/PGQ: NODES(p) and EDGES(p) return nodes and edges (#678), ORDER BY an outer alias, parameters named after keywords, and LIMIT $n and OFFSET $n, with errors that name their clause.
  • A subquery that returns an edge in one part and a node in another returns each as what it is.
  • The query timeout stops every query, also sorts, aggregates, joins and large UNWINDs; range() near the largest integer stops at its end.
  • Vector search: the most similar rows above a threshold come first, a similarity filter gives the same rows with and without the index, embeddings written as lists are indexed (#587), and typed arrays are vectors in Node.js and WASM (#586).
  • Indexes (#678): a name stands for one index, vector indexes keep their name, a text index drops as itself, names with a colon no longer collide, a multi-property index drops whole, CREATE VECTOR INDEX takes IF NOT EXISTS, and SHOW INDEXES and list_indexes() also list the indexes made through the API.
  • Graph types: a node type takes every occurrence's properties, a graph typed by another schema's type is checked against it, DROP GRAPH forgets its type, LIKE an untyped graph is the open type, and SHOW NODE TYPES writes DDL.
  • Introspection: schema() and db.labels() list only what is in use, stored procedure errors say "Procedure", and EXPLAIN and PROFILE show the whole plan as it runs, with each operator's numbers on its own line, also with subqueries and index pushdowns.
  • SPARQL 1.1 (#701, #702, #703): a FILTER inside OPTIONAL reads the whole solution, a group that opens with OPTIONAL or MINUS starts from one empty solution, FILTER EXISTS and NOT EXISTS only filter, BIND to a variable in scope is refused, an unbound variable joins with what follows, and GRAPH ?g over a pattern without ?g gives a row per named graph.
  • SPARQL DELETE WHERE deletes only what a match of its whole pattern holds: without a match it no longer deletes its triples without variables, and every triple of a match goes, not only the first.
  • SPARQL syntax: the standard prefixes are predefined, [] is a blank node, VALUES after the query and ORDER BY a function call parse, EXPLAIN, EXPLAIN ANALYZE and PROFILE work after the prologue, IRIs with \u escapes match, alternative paths with an inverse branch run, xsd:float stays a float, and LOAD says it is not supported. N-Triples and Turtle read a dot right after a blank node or language tag and \u, \U, \b and \f escapes.
  • Durations with a negative fraction of a second: DURATION 'PT-1.5S' is minus one and a half seconds (it read as -0.5 s), and a duration of mixed signs is written P1MT-1.5S (it was P1MT-1.-5S) and reads back in every binding; a duration part past 64 bits or a signed time offset (12:00+-1:00) is refused instead of wrapping around.
  • Dates, times and durations keep their GQL type both ways in Go, C#, Dart and Node.js: Go time.Time and the new grafeo.Date, Time and Duration; C# microseconds, DateOnly, TimeOnly, DateTimeOffset, and the new GrafeoTime and GrafeoDuration; Dart's new GrafeoDate, GrafeoTime, GrafeoDuration and GrafeoZonedDateTime; Node.js the tag objects.
  • Bytes, NaN and infinities travel as $bytes and $float tags in the C, Go, C# and Dart bindings and arrive as native values; Go decodes tags in nodes, edges, streams and AsOf reads, and its streams give numbers as json.Number.
  • Parquet: LOAD DATA FORMAT PARQUET reads DATE, TIMESTAMP, TIME and DECIMAL columns as their types, the Python wheel loads Parquet (uncompressed or Snappy), and another codec is named in the error.
  • Bindings: Node.js ES module imports name every export, Go frees finished transactions, Dart rollback() after a failed commit does nothing, and WASM has detachDeleteNode (#678).
  • Data: decimal weights in graph algorithms (#678) and CSV imports with quoted line breaks (#678).
  • Smaller fixes: a write conflict says so once; a procedure argument that fails keeps its GQLSTATUS; a damaged incremental backup is GRAFEO-S002; PreparedCommit::commit returns its own epoch; CDC events of named-graph triples name the graph; list_graphs is sorted; a JSON parameter with a tag beside other keys is a map; builds with triple-store alone compile.
  • Docs: SPARQL reads in a transaction are not yet snapshot isolated (#718, planned for 0.6.2).

Deprecated

  • GrafeoDB::store(): its writes skip the write-ahead log, transactions and change capture. Write through a session or the direct API, and read through snapshot().
  • The async-storage feature, with GrafeoDB::async_wal_checkpoint and async_write_snapshot, which only wrapped a blocking call: call wal_checkpoint() from tokio::task::spawn_blocking. From 0.7.0 the enterprise profile is metrics and tracing.
  • Config::with_threads, with_query_logging, with_schema_constraints, without_schema_constraints and their getters, which never had an effect.
  • RdfStore::copy_graph: use SPARQL COPY in a session, which is logged.
  • CatalogError::InvalidCheck: nothing returns it; CHECK constraints come back later as DDL.

All are removed in 0.7.0.

Contributors

  • Thanks to teipsum for reporting the SPARQL OPTIONAL, MINUS and BIND bugs (#701, #702, #703), each with its measurements and the section of the spec.
  • Thanks to temporaryfix for decimal weights in the graph algorithms (#681), the test of a failed RDF commit (#683), the refusal of an incremental backup with a gap (#684), crash-safe backups (#685) and restores (#687, #689), backups that run one at a time (#690), time-travel reads that never see a commit in progress (#686), claims released after a rollback (#693) and time travel inside a transaction that checks retained history (#695), CSV fields with line breaks (#692), aggregates inside other expressions (#682), OPTIONAL MATCH patterns that read the rows before them (#698), OPTIONAL CALL of a procedure (#699), stored procedure results that fit their columns (#700), index lists with the indexes made through the API (#704), decimal products and remainders that fit (#691), test fixtures that build with any feature set (#694), direct calls that leave nothing when they fail, with raw store writes that checkpoints keep (#696), SQL/PGQ path nodes and edges (#705), compound types in Cypher type DDL (#706), and detachDeleteNode in WASM (#708).

[0.6.0] - 2026-10-23

Storage release: a new single-file format on a new storage engine, with online checkpoints and backups, snapshot isolation on every read path and time travel over a retained history. Plus exact decimals, a store contract for backends of your own, binding parity across C, Go, C#, Dart, Node.js and WASM, and a large round of correctness fixes in GQL, Cypher, SQL/PGQ and SPARQL.

0.6.0-beta.1 (2026-10-11) was a preview of this release; this section covers everything since 0.5.44.

Breaking release. As a minor release before 1.0, 0.6.0 breaks things (see Versioning). Before you upgrade:

  • Database files are migrated on first open: the first read-write open converts a 0.5.x database to the new format and keeps the old files as .pre-0.6. 0.5.x cannot open the result, so stop every 0.5.x process first; 0.7.0 will not read 0.5.x files. Backups and snapshots have new versions: take a full backup after the upgrade.
  • Minimum Rust version 1.99.0 (was 1.91.1).
  • Some query results change: a GQL or SQL/PGQ number with a point is an exact decimal, overflow and division by zero are errors, GQL sorts nulls last, SPARQL returns RDF terms, and some statements that gave a wrong answer now fail. See the Breaking entries under Changed, and Result changes.
  • Rust API: a new store contract (ReadView, GraphBackend, Snapshot, the row-group store in place of LpgStore), direct reads that return Result, and #[non_exhaustive] configuration and result types.
  • Bindings: a read that fails is an error, never an empty answer; some C, Go, C#, Dart and Python signatures change, and Python RDF terms no longer equal plain strings.
  • CLI: standard output holds only results, and --quiet hides only status lines.
  • Deprecations: what 0.6.0 replaces is deprecated and removed in 0.7.0 (see Deprecated).

Upgrading to 0.6 lists every breaking change and what to change.

Added

  • Time travel: a database can keep its history for a retention window (Config::with_history_retention: HistoryRetention::Duration(..) or Forever; off by default and free when off), stored in the file and kept through checkpoints, crashes and reopens. GrafeoDB::as_of(point) gives a read-only AsOfView as of an epoch, a time, a GQL ZONED DATETIME or TimePoint::ago(duration), with execute_as_of and epoch_as_of; a point older than the retained history fails with "history before ... is not retained". Python, Node.js, WASM, C, C#, Go and Dart set the retention at open and have as_of, execute_as_of and epoch_as_of; a point is never guessed from a bare number (Python: an int epoch, an aware datetime or a timedelta; Node.js and WASM: a bigint, a Date or { ago: milliseconds }; C: GRAFEO_POINT_*; C#, Go and Dart: TimePoint).
  • Exact decimals (#610): up to 38 digits with their scale (1.50), exact in arithmetic, comparison, grouping and aggregates. GQL CAST(x AS DECIMAL(10, 2)) and DEC, toDecimal(x[, precision, scale]) in GQL, Cypher and SQL/PGQ, DECIMAL(p, s) property types in type DDL, and SQL/PGQ CAST(x AS type) for every SQL type it knows, with DECIMAL, NUMERIC, DATE and TIME columns in CREATE PROPERTY GRAPH. Python reads and writes decimal.Decimal, Node.js and WASM {"$decimal": "1.50"}, C#, Dart and Go a decimal type of their own, and the Arrow export Decimal128.
  • Backends of your own (#416): GrafeoDB::with_store(backend, Config::persistent(path)) runs the database's transactions in a GraphBackend of your own, with rollbacks, savepoints and change data capture, and is durable through its own WAL, <path>.wal, replayed after a crash from the epoch the backend reports durable. grafeo::store holds the contract, and the conformance feature publishes the test suite the built-in store passes (conformance_suite!(MyStore, make, GAPS)); the grafeo crate's tests hold a complete example backend. Implementing the contract is experimental through 0.6.x.
  • API stability levels and an upgrade guide (#493): every public API is Stable unless its documentation marks it # Stability: Experimental, in Rust and in the binding docs that wrap it; API Stability lists them and CI keeps both in step. Upgrading to 0.6 lists every breaking change from 0.5.x.
  • The GQLSTATUS of every error (#639, #643): Python GrafeoError.gql_status, Node.js and WASM gqlStatus (TypeScript GrafeoError), C grafeo_last_error_gql_status(), Go's new *grafeo.Error (still matching ErrDatabase), C# GrafeoException.GqlStatus and Dart GrafeoException.gqlStatus give the ISO GQL status (22003, 22012, 42001, ...) next to the Grafeo code. The Node.js package's entry module is now grafeo.js (types grafeo.d.ts).
  • New error codes: GRAFEO-S002 for a damaged file or WAL, naming the file and offset (Python GrafeoCorruptionError, Rust Error::Corruption, which replaces StorageError::Corruption); GRAFEO-T008 after a commit that panicked midway, until the database is reopened (#412); GRAFEO-T009 and GRAFEO-T010 for a commit whose WAL write failed (see Fixed); and GRAFEO-Q004 for what the build or database cannot run.
  • GQL USE g <statement> (grafeo-web#55): a query or write after USE [GRAPH] g runs on graph g, in a transaction or outside, and leaves the session's graph as it is; each operand of a UNION or NEXT chain may repeat it.
  • Query language additions: GQL =~ regular expression match (a Grafeo extension, see Conformance); GQL GROUP BY on a RETURN alias, with items computed from the keys; Cypher RETURN *, r.years AS y; Gremlin anonymous traversals __. (#631); SQL/PGQ DROP PROPERTY GRAPH; and the numbers .5, 5. and 1_000.
  • Binding parity (#441): C gets node and edge batches with properties, batch vector search, write counters, change history, upserts with their summary and graph handles (grafeo_graph(db, name)), and Go, C# and Dart wrap all of it. Node.js gets graph handles (db.graph(name)). WASM gets write counters, batch vector search and, in the lpg and full builds, node and edge batches, upserts and db.graph(name); executeRawWithParams and executeRawWithLanguageAndParams (#574); and executionTimeMs on raw results.
  • A node's edges without a query (#402): get_node_edges(node, direction, edge_types) returns (neighbor, edge) pairs, in Rust, Python, Node.js, C (typed id arrays), Go, C#, Dart and every WASM build. detach_delete_node(id) (#465) removes a node and its edges as one change in every binding; change data capture marks each removed edge with deleted_with.
  • Automatic checkpoints by WAL size and interval (#426): set_checkpoint_triggers (Rust, Python, Node.js, C, Go, C#, Dart) and Config::with_checkpoint_wal_size, off by default. checkpoint_status() reports the triggers, the last checkpoint and failed automatic checkpoints with the last error; a failure is logged and retried.
  • Query metadata (#461, #464): PROFILE results hold the operator tree as data (QueryResult::profile, result.profile, WASM profile; experimental), and PROFILE names an operator a fast path absorbed instead of listing it with 0 rows. rows_scanned now counts the rows read from storage, and every query, in every language, reports execution_time_ms. QueryResult::kind (grafeo::ResultKind) says whether a result holds rows, a boolean (SPARQL ASK) or a graph (CONSTRUCT). EXPLAIN names the index a MERGE uses (#288).
  • Graph algorithms on projections and named graphs (#566): projection= in db.algorithms, a projection argument for algorithm procedures, and db.graph(name).algorithms. Undirected PageRank (directed=False, {directed: false}), and key= to key per-node results by a node property, which also makes PageRank, Louvain and label propagation independent of load order (see Determinism).
  • Search filters and text index options (#397, #351): hybrid_search and text_search take the property filters of vector_search, applied before fusion so a scoped search returns up to k results. A text index takes BM25 k1 and b, a tokenizer (simple, standard, cjk_bigram) and stop words, through every binding and CREATE INDEX ... USING TEXT {...}, kept with the index.
  • ZONED DATETIME, LOCAL DATETIME and typed lists as property types (#569), in every form of type DDL, checked on write and kept after a reopen.
  • Rust API additions: Session::statement_writes (whether a statement writes, without running it), grafeo::import::import_nodes (CSV or JSON Lines, behind the Python, Node.js and CLI imports), GrafeoDB::set_stored_config, Config getters for every setting and the builders with_wal(), without_wal(), without_schema_constraints(), with_cdc_retention(), with_encryption() and with_wal_salvage(). The grafeo crate exports StorageFormat and CdcRetentionConfig, and its new encryption feature EncryptionConfig, KeyChain, PasswordKeyProvider and restore_to_epoch_with for encrypted backups. The first two calls are experimental.
  • C library release archives with every feature (#636): grafeo-c-<tag>-<target>-default and -all archives with the library, grafeo.h and a FEATURES.txt; all exports every query language. The grafeo-<tag>-<target> archives hold grafeo.h too, and checksums.txt lists every archive.
  • Python 3.15 is tested and listed as supported; the existing wheels install on it.

Changed

  • Breaking: a new single-file format, migrated on first open (#555): every database is one file, whatever its extension, with its WAL in <path>.wal while it is open. The first read-write open of a 0.5.x .grafeo file migrates it and keeps it as <file>.pre-0.6 (WAL <file>.pre-0.6.wal); a 0.5.x WAL directory becomes a file at the same path, with its indexes (#401), and is kept as <path>.pre-0.6/. Read-only opens leave a 0.5.x database as it is. A migrated vector index gets the default parameters (cosine distance), with a warning: create it again if it used others. Properties 0.5.x stored as null, counter values among them, are gone after the migration. The directory must be writable, and builds without the wal feature refuse a 0.5.x database whose WAL holds changes. Opening a directory that is not a 0.5.x database fails, an empty one included, and save() fails on an existing path. See Upgrading from 0.5.
  • A new storage engine: the graphs live in a row-group store (#432), written in chunks of at most 65,536 rows and 1 MiB (#555), so checkpoints and opens never hold a whole section in memory (see Memory) and a node can have any number of labels. The catalog is stored as typed records (#517), the WAL is a chain of checksummed segments (encrypted per segment for an encrypted database), RDF triples are stored as term ids, and a file records its format revision, which a build that does not know it refuses. Vector and text indexes are built from the data when a database opens, so an open with large indexes takes longer.
  • Upgrading from 0.6.0-beta.1: a beta WAL that still holds commits is replayed and checkpointed on the first read-write open. A beta file with RDF triples is rewritten to the term-id format and the old file and WAL are kept as <path>.pre-0.6.0 (the beta cannot open the rewritten file; a read-only open reads it as it is). Beta snapshots and incremental backups still restore.
  • A database file keeps the configuration it was created with (#551): the graph model, backward edges, schema constraints, WAL and sync mode, memory limit, tier overrides and an in-directory spill path apply to every open that does not set them. The graph model and backward edges are fixed at creation: an open that sets another value fails. Change the stored values with GrafeoDB::set_stored_config.
  • Checkpoints no longer hold writers off, and write only what changed (#430, #449): commits wait only while a checkpoint takes its snapshot and writes the catalog and RDF data, and it writes only the changed chunks of the graph (after one value set in 70,000 nodes, two chunks).
  • Backups and snapshots: backup_full() runs online from a snapshot, without a checkpoint first (#362), and so do save(), to_memory() and export_snapshot(); save() and backup_full() write to <target>.partial and rename it when complete. Incremental backups copy WAL segments (backup file version 2): take a full backup after the upgrade. Breaking: the backup manifest and cursor are JSON, which 0.5.x cannot read. Snapshots are version 5 and keep the whole catalog (constraint and index names, index options, graph types, schemas, procedures) (#551); version 4 snapshots still import.
  • Breaking (Rust): compact() returns a CompactReport and no longer builds a separate store: it checkpoints a persistent database, drops the versions no reader needs and encodes the property columns in memory, which reads keep using without decoding. Transactions, indexes and named graphs work the same before and after it, and it no longer needs the compact-store feature. layered_store(), compact_tiered(), the compact_tiered module and admin::CompactionStats are removed. A database compacted by 0.5.44 or older opens with its compacted data folded in (see Compact Store).
  • Commits are pipelined: commits of several sessions that wait for the disk in Sync mode share one sync, and a transaction that starts while another commit is in progress reads the last published commit instead of waiting; a write to what that commit wrote fails with the retryable GRAFEO-T001. Change data capture timestamps events in commit order.
  • Writes outside a transaction are transactions of their own (#536): every write statement, direct call and batch call with no transaction open commits on its own and leaves nothing when it fails, also with auto-commit off; one made while a transaction is open runs beside it and conflicts (GRAFEO-T001) with what that transaction changed first. DROP GRAPH and the SPARQL CLEAR, DROP, COPY, MOVE and ADD fail with GRAFEO-T001 while an open transaction has changes in the graph. The planner's statistics count committed data only.
  • Reads of an earlier epoch read the retained history: execute_at_epoch, execute_as_of, Session::set_viewing_epoch and the *_at_epoch reads see the values, labels, nodes and edges of then, in every build, and fail for an epoch older than what the database keeps (without a retention window, any epoch before the last write). Breaking (Rust, Python): get_node_at_epoch, get_edge_at_epoch and get_node_property_at_epoch return a Result, Ok(None) for absent and an error (Python GrafeoError) for an epoch not retained.
  • Breaking: a GQL or SQL/PGQ number with a point is an exact decimal (#610): 0.1 + 0.2 = 0.3 holds and 1.50 keeps its digits. 1.5e0 and the suffixes F and D make a float, M a decimal. Decimal with integer gives a decimal, with float a float; sqrt, sin and the other float functions return a float. Write 1.0e0 / 0.0e0 for an infinity. Cypher 1.5 stays a float.
  • Breaking: integer and decimal overflow and division by zero fail the query (#639): in GQL, Cypher and SQL/PGQ they fail with GQLSTATUS 22003 or 22012 (code GRAFEO-Q006), where they gave null; a sum adds in 128 bits and fails only when the final value does not fit. Float operations keep their IEEE results, null stays null, and AND and OR skip their right side when the left decides, so WHERE n.v <> 0 AND 10 / n.v > 1 is safe.
  • Breaking: SPARQL numbers follow the XPath type promotion of SPARQL 1.1 (#610): 1.5 is an exact decimal, 5 / 2 is 2.5 (it was 2), and integer arithmetic past 64 bits goes on as decimals. Computed numbers are literals in canonical form, the XPath casts (xsd:decimal(...), xsd:integer and the others) now convert, and comparisons with NaN are false.
  • Breaking: SPARQL results hold RDF terms (#464): each IRI, blank node and literal comes back as the exact term with its lexical form, language tag and datatype, where it was a string or a number (Rust Value::RdfTerm; Python grafeo.IRI, Literal and BlankNode, subclasses of str; RDF/JS terms in Node.js and WASM; RdfTerm in C#, Go and Dart; a $term tag in the C library's JSON). ASK gives one boolean. GraphQL over RDF still gives plain values, and the Arrow, pandas and polars exports give a literal of a known XSD type as its native value. Breaking (Python): the term classes compare and hash as RDF terms, as rdflib's do, so IRI("http://example.org/a") != "http://example.org/a"; compare with str(term).
  • Breaking: new limits and refusals: a GQL WHERE or FILTER right after INSERT, CREATE, MERGE or DELETE is an error (it filtered before the write); a statement nests at most 64 levels and its plan at most 128 (#573), where long AND, OR and UNION chains and INSERT lists do not count; type DDL refuses a property type Grafeo does not support (INT32, a typo), which declared ANY; property values nest at most 128 levels (a 0.5.x database that holds a deeper one fails to migrate: change it with 0.5.x first); and SESSION SET TIME ZONE takes a UTC offset only ('+01:00', 'Z'), failing with 22009 for a named zone, which was stored and never used. Rust Session::set_time_zone returns a Result.
  • Breaking: GQL sorts nulls last by default (#571), ascending and descending; Cypher keeps the openCypher order.
  • Error codes say what kind of mistake it is (#588): a mistake in the query text is GRAFEO-Q002, what the build or database cannot run GRAFEO-Q004, a value or name a call gives that does not fit GRAFEO-V001, a statement that fails on its data GRAFEO-Q006, a direct write to a missing node or edge GRAFEO-V002 or GRAFEO-V003, and GRAFEO-X001 now means a bug in Grafeo. Every query error message starts with its code. QueryResult::gql_status is 00001 for a statement without a result.
  • Faster planning around indexes and labels (#456, #457, #455, #540, #546, #463): an indexed IN inside an AND chain uses the index, a label in WHERE (or an OR of labels) scans only that label, a multi-label pattern scans its smallest label, a later MATCH joined by property values uses a hash join (300 files and 3,000 type definitions: 303 ms to 3 ms), function calls resolve once per query, schema() counts labels without building nodes, and DISTINCT over a variable-length pattern finds each reached node once. Edge upserts pinned by id(), WHERE id(tgt) IN $ids on an edge target and a WHERE after OPTIONAL MATCH ... WITH no longer scan every node. EXPLAIN shows what is used.
  • Faster execution: a repeated statement without parameters skips parsing, a statement with parameters is planned once and refilled per call (as prepared statements are), filters and projections read only the property they need (2.5 to 3 times faster than 0.5.44 over many rows), label scans check 64 rows' visibility at once, edge reads skip the other end, SPARQL joins, DISTINCT and CONSTRUCT run on term ids (20 to 66% faster), conditions on variable-length edges prune the search as it walks, and a file database no longer creates a spill directory per statement (#565).
  • Direct single writes are slower than in 0.5.44: create_node, set_node_property and create_edge take about 0.1 to 0.2 microseconds more per call, as each commits like a transaction. Batch calls and upserts commit once and are the fast path.
  • Breaking (Rust): the store contract (#416, #432): GrafeoDB::store() returns the row-group store (Result<Arc<RowGroupStore>>, experimental, the unsupported error on a backend of your own), and LpgStore, LpgStoreSection, PropertyStorage, NodeRecord, EdgeRecord, GraphStoreMut, ExternalTarget, UndoSupport, WriteTarget and graph_store_mut() are removed: write through sessions or the direct API. GraphStore has one family of reads, each taking a ReadView and returning a Result (GraphStoreSearch is merged in), and graph algorithms take a store and a view. GrafeoDB::with_store takes an Arc<dyn GraphBackend>. snapshot(), GraphHandle::snapshot(), AsOfView::snapshot() and projection(name) give a pinned Snapshot, replacing graph_store() and selected_graph_store(), and algorithms read through one. iter_nodes() and iter_edges() return a Result. The upgrade guide has before and after tables.
  • Breaking (Rust and every binding): a read the store cannot do is an error, never an absent answer (#416): the direct reads, counts and reports of GrafeoDB, Session and AsOfView (get_node, node_count, info, schema, validate and the rest) return a Result, a direct read of a dropped graph fails, and a store without history answers history reads with the unsupported error. Python raises GrafeoError, Node.js and WASM throw. C: grafeo_node_count and grafeo_edge_count write to a new size_t*, grafeo_get_node_labels and grafeo_as_of_get_node_property to a new char**, the get_node and get_edge calls return GRAFEO_OK with NULL for an absent one, and grafeo_info returns NULL with the error. Go: NodeCount() and EdgeCount() return (int, error) and an absent node or edge is ErrNotFound. C# NodeCount and EdgeCount throw on a failed read and GrafeoGraph.GetNode and GetEdge return null; Dart getNode, getEdge and getNodeLabels return null for an absent one.
  • Breaking (Rust): other API changes: grafeo::Error is 32 bytes with boxed payloads (build variants with Error::from or Error::type_mismatch); Config and the option and result types are #[non_exhaustive] (build them with their constructors and with_* methods, name fields in patterns with ..); drop_graph, create_property_index, the index drops, batch_insert_rdf and create_projection return Result (in C the drop and projection calls return -1, the projection calls as int32_t); hybrid_search and text_search take filters (pass None); GrafeoDB::wal() and GrafeoDB::file_manager() return the new WAL writer and file manager; and Config::wal_flush_interval_ms, GrafeoDB::adaptive_config(), admin::DumpMetadata and the Block-STM metrics (block_stm_*, also on grafeo-server's /metrics) are removed.
  • Rust implementation crates: QueryProcessor::with_graph and TransactionState::Committing are new, QueryError::semantic and QueryError::unsupported build errors and Error is Clone, Term::from_ntriples returns Result<Term, TermParseError>, the engine's wal feature enables grafeo-file, and the deprecated TieredStore trait is removed.
  • Breaking (bindings): Python db.algorithms.kcore() returns {"core_numbers": {...}, "max_core": n} (#563); Go DropVectorIndex returns (bool, error); and the Arrow export (Python to_arrow(), nodes_df() and the polars calls, Node.js toArrowIpc(), the CLI's Arrow dump) keeps lists as lists, maps as structs and durations as structs of months, days and nanos, where they were text.
  • Breaking (CLI): standard output holds only results: progress and status lines go to standard error and --quiet hides only those, so -q no longer hides query rows. With --format json every command prints JSON. The commands that only read (info, stats, schema, validate, data dump, index list, index stats, wal status, a query that does not write) open the database read-only, so they no longer migrate a 0.5.x file.
  • Python: long-running calls release the GIL: queries, commits, algorithms, searches, batch writes, imports, exports, backups, checkpoints and close() let other threads run, so several threads can query one database. A commit() or rollback() while another thread runs a query of the same transaction raises RuntimeError.
  • embed: ONNX Runtime bindings 2.0.0-rc.14: Grafeo builds the process's one ONNX Runtime environment when it loads its first model; an application that builds its own first cannot load Grafeo embedding models in that process.
  • Dependencies: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 for RUSTSEC-2026-0382 and RUSTSEC-2026-0383 (reached only by the model download of the opt-in embed feature), crc32fast 1.5.3, unicode-normalization 0.1.26, and the Python bindings on PyO3 0.30 (no API change).

Deprecated

All of these are removed in 0.7.0.

  • Cargo features that enable nothing: temporal (history is the retention setting, in every build, and the calls it gated are always there), compact-store (every build that opens files reads a compacted 0.5.x database), temporal and tiered-storage of grafeo-core and grafeo-common, and lpg of grafeo-core, grafeo-adapters, grafeo-engine and grafeo-cli (every build has the property graph store; the lpg profile of the grafeo crate and the bindings stays). Python's grafeo.features() no longer lists them.
  • Rust: the public fields of Config: read a setting with its getter and set it with its builder; a field written directly does not count as set, so a database file's stored value wins over it.
  • Rust: Session::set_auto_commit and Session::auto_commit (#536): a write outside a transaction always commits on its own; group writes with begin_transaction.
  • Single-file and adaptive leftovers: StorageFormat::SingleFile and WalDirectory, the C, Go and Dart grafeo_open_single_file, OpenSingleFile and GrafeoDB.openSingleFile (use the plain open), and Config::adaptive, with_adaptive, without_adaptive and AdaptiveConfig, which never had an effect.
  • recompact(), which calls compact(), and Rust BackupCursor::log_sequence (use wal_lsn).
  • Rust (grafeo-core): InvertedIndex::with_tokenizer (use with_options with a TokenizerKind), and the per-transaction methods of RdfStore (insert_in_transaction, commit_transaction and the rest): run updates in a session transaction.

Fixed

  • A commit whose WAL write fails is no longer reported as committed (#498): it is rolled back and fails with the retryable GRAFEO-T009, or with GRAFEO-T010 (outcome unknown) when the write landed but could not be synced. Before, it was logged as a warning and lost on the next restart.
  • Recovery stops at damage in the WAL (#425): an open that finds damage before the end of the log fails with GRAFEO-S002, naming the file and offset, and changes nothing, where 0.5.x skipped past it and built a state that never existed. A torn tail is still cut. Config::with_wal_salvage() (Python wal_salvage=True, Node.js walSalvage: true) replays the commits before the damage and keeps the rest in <file>.wal.damaged.
  • Commits that were lost on a crash or reopen: a write after close() now fails with GRAFEO-T007 (Python DatabaseClosedError) instead of being accepted and lost; a read-only open replays what a crashed writer left in the WAL (a build without wal refuses such a file); an open with the WAL turned off no longer drops a leftover WAL, and a new database no longer replays one from an earlier database at its path; imports and RDF batch inserts are durable once they return; SPARQL COPY, MOVE, ADD and CLEAR ALL replay as written; and indexes (#401), schema details (defaults, parent types, edge endpoints, KEY labels, ALTER additions) and graph drops survive a crash.
  • Files and backups: databases larger than 4 GiB work (#392) (0.5.42 and older wrote them corrupt); ids of deleted nodes and edges are never given out again after a reopen; epochs continue after a reopen and after the migration; timestamps keep their sub-millisecond part and counter values survive a checkpoint; a damaged file is refused instead of aborting the process; incremental backups work across a restart (#267); backups of a read-only database hold the state it shows and a restore replays every incremental backup after its full one (#362); grafeo backup restore --force replaces the target only once the restore is complete; open_in_memory() no longer opens its source read-write; and the same data exports to the same snapshot bytes.
  • Data that 0.5.x silently dropped: encryption at rest did nothing, and now encrypts the file and its WAL (an open without the right key fails; encryption with a spill path, TierOverride::ForceDisk or no path fails, and a migrated database's .pre-0.6 copy stays unencrypted); spilled vector embeddings were not in the database file (#594) (see Spilled embeddings); and a build without triple-store, vector-index or text-index opened a database without that data and dropped it at the next checkpoint, and now refuses it (#544).
  • 0.5.x's compacted store is gone, with its bugs (#542, #558, #596): writes after compact() are no longer lost, ids stay unique and every read sees the whole database. A node with several labels in a compacted file matches each of them again (#595). What compact() of 0.5.40 to 0.5.44 lost stays lost (nodes without labels, indexes, typed lists and maps, deleted items that come back): see Compact Store.
  • Rust: GrafeoDB::open compiles with the default features (#379); without wal, commits reach the file at a checkpoint.
  • Snapshot isolation holds on every read path (#412): other sessions and direct reads no longer see an open transaction's values, labels or deletes, a transaction reads one state throughout (filters, index lookups, label scans, searches), and checkpoints, close(), save(), backups, to_memory() and export_snapshot() write the committed state while a transaction is open. Graph algorithms and the Python reads (get_nodes_by_label, as_networkx()) see committed data only, and a write during a commit no longer lands in the middle of it (#548).
  • Constraints and transactions: a UNIQUE, PRIMARY KEY or NODE KEY value holds between concurrent transactions (the second writer gets GRAFEO-T001), and a transaction may set such a key to the value its own node holds. An edge to a node deleted in the same statement or concurrently is refused. A refused schema statement, a failed stored procedure that writes, and a SPARQL or GraphQL update that fails part way (#414) change nothing; a stored procedure that writes is refused where writes are. Labels set on a node created in the same transaction are kept, a rollback to a savepoint restores labels, a rollback no longer deadlocks with a delete, read-only databases refuse SPARQL updates, imports and snapshot restores, Python import_df and WASM importRows and importLpg import all or nothing, and gc() collects every graph.
  • A GQL DELETE of a variable nothing binds deleted every node: DETACH DELETE n without a MATCH now fails with "Undefined variable 'n'". A plain DELETE of a node with incoming edges in a database without backward edges now fails in release builds too.
  • Clauses after a write read the whole write (#455): a RETURN, WITH, WHERE, aggregate, MATCH, OPTIONAL MATCH, MERGE or subquery after a write in the same statement read only what earlier rows wrote; they now read after the whole write, as ISO GQL and openCypher define a statement. A LIMIT or FINISH no longer cuts the write short, a write takes values from nodes created earlier in the statement, FOREACH and unit CALL subqueries keep the number of rows, and a statement that ends with a write returns no rows and no columns (#580).
  • WHERE, OPTIONAL MATCH and subquery scope (#455, #545): each condition is checked where every value it reads is bound; the WHERE of an OPTIONAL MATCH is part of its pattern, so every row stays (filter with WITH * WHERE or GQL FILTER, which now filters every row); a later comma-separated MATCH part goes on from a variable bound before it; a vector or text search after a MATCH keeps its rows; CALL subqueries keep their imported variables after a WITH; an UNWIND after WITH ... WHERE unwinds the rows kept; a condition without variables (WHERE $p = 1) no longer fails with "Empty plan"; and an index lookup at a past epoch no longer matches the values of now.
  • GQL statements in any order the standard allows (#483): ORDER BY, OFFSET and LIMIT before the final RETURN, WHERE and FILTER between MATCH statements, writes after WITH and after each other, LET and FILTER first, and a final CALL that writes. Also fixed: NEXT after a write, SELECT ... FROM g, HAVING right after GROUP BY, backticked names in SET and REMOVE (they kept their backticks), doubled quotes in strings, collect_list, HOME_SCHEMA and HOME_GRAPH, and SESSION RESET to the session's home.
  • MERGE follows openCypher: a pattern is matched or created as a whole, a node the pattern does not connect is not reused, an anonymous node merges, ON CREATE SET n:Label adds the label, Cypher SET n = {...} replaces the properties, and Cypher and GQL MERGE and Cypher CREATE stored a left-pointing relationship backwards (find and reverse such relationships 0.5.x wrote).
  • Cypher follows openCypher: <-[:T]-> and <--> match either direction, a relationship without a type or a direction cannot be created, IN and STARTS WITH bind tighter than comparisons, a relationship is matched at most once per MATCH, chained comparisons (a < b <= c) work, exists((n)-->()) tests the pattern, pattern comprehensions read the row, :A|:B parses, a clause after RETURN is an error, a CALL after other clauses runs per row, an inline WHERE on a variable-length relationship holds per edge, and CREATE CONSTRAINT and CREATE INDEX work without a name and take USING TEXT, VECTOR or BTREE (#344).
  • Paths (#590, #591, #572, #318): a path variable holds every node and edge of a multi-edge pattern; DIFFERENT EDGES works and TRAIL, ACYCLIC and SIMPLE hold for the whole path; shortest-path searches bind their path and edge variables and apply the edge's conditions; MATCH ANY keeps a path per pair of endpoints and SHORTEST k keeps k; path modes after the path variable parse; a WHERE on a quantified edge holds per edge; horizontal aggregates (sum(e.w) over -[e]->{1,3}) work; and length(p) reads a passed-on path.
  • Variable-length patterns end and stay in memory: a pattern on a graph with cycles could abort the process allocating tens of GB; rows now stream, and a search that would exceed a quarter of memory_limit fails with advice. A path of 100,000 edges no longer overflows the stack, unbounded patterns no longer stop after min + 100 hops, SPARQL + and * no longer stop after 50, and an undirected pattern takes a self-loop once. -[:A:B]-> and a created edge with :A|B are errors.
  • Very large statements run (#573): an AND or OR of 10,000 conditions, an INSERT of 10,000 patterns or a UNION of 10,000 queries overflowed the stack, aborting native builds and breaking the WASM module for the rest of the page; they now run in every query language.
  • Aggregates and grouping (#589, #602): values beside an aggregate with a computed key no longer turn into 0; a spilled group keeps aggregating; aggregates skip nulls; grouping and DISTINCT treat 3 and 3.0 as one value and keep float keys as floats; min and max order mixed types as ORDER BY does; DISTINCT reaches the statistical aggregates; two-argument aggregates read their second argument; listagg and group_concat take their separator; HAVING computes its own aggregates; RETURN count(*) without MATCH works; and an aggregate in a WHERE, a wrong number of arguments or an item that mixes an aggregate with a non-key is an error, as are the GROUP BY mistakes, each with its reason.
  • ORDER BY: GQL and Cypher ORDER BY read what openCypher's does (the input of a plain WITH, the columns of an aggregating one), the GQL ORDER BY statement also puts nulls last, and with DISTINCT only returned values can be sorted on.
  • Values keep what they are: nodes and edges inside lists, maps and paths stay nodes and edges, startNode(r) and endNode(r) return nodes, list and map literals keep a missing value as null, a property named _id, _labels or the like no longer replaces the identity (#254), a list, map or bytes parameter returned as is no longer comes back as '' (#574), a variable named like an internal one keeps its value, UNWIND keeps its list variable, size() counts characters, and id, elementId and properties read any expression.
  • Functions (#570, #179): an unknown function or a wrong number of arguments fails before any row is read, also in EXPLAIN, with the closest name as a hint, where it was null (including toStringOrNull, isEmpty, randomUUID and other names Grafeo lacks); path_length(p) works; an invalid regular expression, a build without regex or text-index support and create_vector_index without vector-index are errors; the clock functions read one instant per statement; rand() gives a new value per row, also in WASM; and normalize, LIKE across line breaks, cosine_distance of equal and opposite vectors, abs of the smallest integer (22003) and unary + and - (a type error for non-numbers) are fixed.
  • Temporal values (#584): zoned datetimes order by their instant and compare with timestamps; date, time and datetime take a zoned datetime; toString of a local datetime has no zone; zoned datetime arithmetic keeps the offset; a duration multiplies and divides by any number; GQL compares zoned and local values at the session time zone; Cypher datetime() and time() are zoned and keep their offset, and datetime({epochMillis: n}) and temporal components work.
  • Comparisons and typed properties (#535, #568): = returns the same rows with or without a property index; a node or edge no longer equals its id; IS TYPED checks every type of type DDL; a FLOAT64 property takes integers; adding a label converts values to its types; an integer past 64 bits is an exact DECIMAL in imports, parameters and every binding; and type errors name types as DDL spells them.
  • Schema and DDL (#567, #423): a closed graph type refuses what it does not declare; types created in a schema check writes there; defaults may be negative, a default of the wrong type is refused, and graph type and edge type defaults apply; DROP GRAPH TYPE refuses a type in use, and a graph type created under a dropped one's name no longer takes over its graphs; a graph type in braces declares its element types; MAP properties take maps; property names like starts and match can be declared; CREATE GRAPH dst AS COPY OF src copies the graph; SHOW INDEXES keeps every row of a multi-property index after a reopen; refused writes report G2000 or G1001; and SHOW and EXPLAIN of a write run in a read-only database or transaction.
  • Parameters, procedures and PROFILE: SPARQL parameters bind their variables; parameters beside an aggregate, in HAVING and in the second argument of a binary aggregate are filled in; a streamed statement with a missing parameter fails as execute does; the plan cache keys a statement by its exact text; CALL arguments take parameters and expressions; stored procedure arguments are bound as values; a query past its timeout is the retryable GRAFEO-Q003 in every plan; and PROFILE of a query that starts without MATCH no longer panics.
  • SQL/PGQ: CREATE PROPERTY GRAPH creates the types and graph it names; aggregates work inside SELECT and HAVING expressions; GRAPH_TABLE columns read through the table alias; property maps apply to every element; LIMIT and OFFSET apply after grouping; -[:T*]-> is one or more hops; HAVING on a grouping column and RELATIONSHIPS(p) work; an aggregate in COLUMNS is an error that says to aggregate in the SELECT; and the SQL/PGQ-only build compiles.
  • SPARQL terms and operators (#464): DELETE removes the stored term and templates write the bound one, literals of unknown datatypes keep them, operators and ORDER BY follow SPARQL 1.1, language tags survive expressions, ROUND, double division, date casts and isNumeric follow XPath, an IRI holds no space, and non-ASCII literals survive a reopen.
  • SPARQL queries: GROUP BY (expr AS ?v), ORDER BY of any pattern variable, aggregates inside HAVING (GROUP_CONCAT included), SELECT expressions over aggregates, SUM and AVG of no values (0), empty VALUES, unbound selected variables, MOVE and COPY of a graph onto itself, and CLEAR NAMED (which cleared the default graph too). An empty named graph no longer takes 1.3 MB, execute_sparql reports updates to change data capture, and sessions run SPARQL and GraphQL PROFILE.
  • Vector and text search (#593, #600, #391, #619): a query vector of the wrong size or with NaN fails with GRAFEO-V001 instead of crashing the process, and an indexed vector property refuses NaN; removing a vector, a full neighbor list or many identical vectors no longer leave others unfindable; equal scores rank by node id, so identical searches give identical results, also after a reopen; and memory_usage() counts named graphs, indexes and what values hold.
  • Graph algorithms (#592): results come in node-id order and are bit-identical across runs, platforms and thread counts; algorithms no longer read uncommitted writes (betweenness centrality crashed); CALL grafeo.dfs reports tree depth with discovery and finish columns; articulation points take linear time; and Python as_networkx(), as_solvor(), get_nodes_by_label and get_property_batch follow set_graph().
  • Databases on a store of your own (with_store, with_read_store): sessions are set up like any other, what only the built-in store has (indexes, searches, snapshots, to_memory()) is the unsupported error instead of a panic, Session reads read that store, validate() reports instead of panicking, and a label outside a projection's spec is found.
  • Imports: CSV and JSON Lines imports keep every column and key (spaces, punctuation, a byte order mark, late keys), and import_jsonl works in the Python and Node.js packages. grafeo data load connects edges to the nodes the file names, keeps plain values and runs in one transaction (#537).
  • CLI: JSON and CSV results keep their values and types, an error prints its cause, grafeo index list shows the index's name, and grafeo query --lang cypher with --param runs Cypher.
  • Bindings: Python bytes, aware times and dates round-trip, nodes_df() gives the same values with pyarrow, and the polars exports work on polars 2.0. Node.js and WASM convert parameters and rows as properties do, ResultStream.next() has a valid type, WASM timings and clock functions work, and executeRawWithLanguage runs in the open transaction (#574). C fails on invalid params JSON, the C# NuGet package bundles every query language (#636), C# and Dart integer types arrive as integers, Dart currentSchema() no longer crashes, and Gremlin nested and(), or() and not() filters apply. changes_between returns a commit's events in write order.

Result changes

  • Numbers: float = and <> are exact (IEEE 754) and an integer equals only a float of exactly its value, < and > compare integers with floats exactly, and a float literal past the float range is an error (22003 in GQL, a syntax error in Cypher). Use decimals for exact fractions.
  • LIMIT, SKIP, OFFSET and WITH: a negative count is 22G02 and a non-integer 22G03, written or as a parameter; a WITH with two items of one name is an error (#371).
  • CAST, SUM and AVG: CAST trims spaces around a text and converts booleans and numbers into each other, in GQL and SQL/PGQ (Cypher keeps openCypher's rules); sum and avg take numbers or durations only (strings were summed), and durations now sum and average.
  • text_match and text_score need a text index (#308): on a property without one the query fails, naming the CREATE INDEX to run, where every node scored 0.
  • GQLSTATUS names the kind of error: an unresolved name stays 42002, any other rule a statement breaks is 42000, and an unsupported feature has GRAFEO-Q004.
  • SQL/PGQ GRAPH_TABLE (g MATCH ...) reads graph g, where it read the session's graph; the form without a comma is accepted.
  • Graph algorithms (#563, #564, #566, #592): k-core returns core numbers; Louvain merges communities level by level (a 1,000-node path: 500 pairs at modularity 0.50 before, 31 communities at 0.94 now); as_networkx(directed=False).pagerank() is undirected; db.algorithms and create_projection follow the selected graph; clustering coefficients and stochastic block partition no longer change between calls; clustering, triangle counts and k-truss ignore self-loops; and strongly connected components are numbered in another order.
  • SPARQL: COUNT in a transaction counts its own writes, DROP ALL drops every graph, and ASK returns one row with a Boolean in the column boolean.

[0.5.44] - 2026-10-04

Durability and consistency release. Crash-safe checkpoints and WAL recovery, indexes and constraints that survive a reopen, schema checks on every write path, per-graph conflicts and grants, commit and rollback in O(changes), and fixes for shortest paths, variable-length edges, list comprehensions, subqueries, OPTIONAL MATCH and MERGE. Plus graph handles, upserts by key and write counters.

Heads-up: 0.6.0 (first announced as 0.5.45) changes the on-disk format. 0.6.0 migrates a database automatically on first open (WAL-directory databases become a single .grafeo file); after that, 0.5.44 and older can no longer open it, so keep a backup if you may need to go back.

Changed

  • Breaking (Rust, grafeo-engine): the direct write methods return Result (create_node, create_edge, set_node_property, delete_node, the batch calls and the rest, on GrafeoDB and Session). Each call commits on its own with the checks of a query, so writing to a missing entity or endpoint, or deleting a node that still has edges, now fails. The bindings raise these errors.
  • Breaking (Rust, grafeo-core): the mutation operators take a GraphWriter instead of with_transaction_context, with_validator and with_write_tracker; SetPropertyOperator::with_labels and with_edge_type are gone.
  • Breaking (Rust, grafeo-core): operators that only order, cut, deduplicate or compare rows take no output schema (LimitOperator, SkipOperator, LimitSkipOperator, SortOperator, TopKOperator, DistinctOperator, ShuffleOperator, ExceptOperator, IntersectOperator).
  • Breaking (Rust, grafeo-core): a sort key's NullOrder no longer flips for a descending key (SortKey::descending sets NullsFirst), and value_utils::compare_values_with_nulls is replaced by compare_sort_values.
  • Breaking (Rust, grafeo-core): FilterExpression::ExistsSubquery and CountSubquery have end_var and edge_var, and ExistsSubquery reads min_hops and max_hops.
  • Breaking (Rust, grafeo-engine): the transaction manager's write and read sets hold GraphEntity (graph plus node or edge), the logical ExpandOp has a quantified field, and RdfPlanner::with_wal is no longer public.
  • Breaking (Rust, grafeo-engine): ChangeEvent has a graph field (None for the default graph), and CdcLog keys its events by graph and entity, with history_in and history_since_in for a named graph.
  • Breaking (Rust, grafeo-adapters): the GQL QueryClause::InlineCall has scope and combined fields, and the Cypher Clause::CallSubquery is a struct variant with query, scope, unions and union_all.
  • CDC label change events: labels is now always the labels after the change, and the new before_labels holds the previous ones.
  • Some queries that ran now fail with an error, as in openCypher: an expression in WITH without a name; a CALL subquery that returns an outer variable or reads one it does not import (CALL () { ... }, a Cypher CALL { ... } without an importing WITH); an importing WITH with a WHERE, alias, ORDER BY, SKIP or LIMIT; UNION mixed with UNION ALL; and a Cypher query that ends with a CALL subquery that returns rows (add a RETURN after it). Before, they gave wrong or silently null results, or failed with an internal error (see Fixed).

Fixed

Every query result in the differential test corpus that differs from 0.5.43 is listed, with its reason, in scripts/difftest/reviewed/0.5.44.txt.

  • Checkpoints after compact() left data out of the .grafeo file: the schema, indexes and RDF data were dropped (also at close()), the periodic checkpoint wrote the pre-compaction store, and async_write_snapshot() wrote little or nothing. Every checkpoint now writes the whole database.
  • A failed checkpoint could leave a .grafeo file that no longer opened (#418). Checkpoints now write to <file>.checkpoint first, so the last good state stays readable; this needs free disk space for a second copy of the file.
  • Edges appeared twice after a checkpoint followed by a crash (#417). Replay now skips entities the file already holds and repairs affected databases, and checkpoints trim the sidecar WAL.
  • Direct writes were not durable until close() (#395). Each call is now durable when it returns, and batch calls are recovered completely or not at all.
  • Concurrent transactions could corrupt WAL recovery (#411): one session's rollback could erase another's commit, and open or rolled-back writes came back on reopen. Each transaction is now logged as one group at commit.
  • Two processes could open the same WAL-directory database (#405); it is now locked like a .grafeo file.
  • wal_checkpoint() lost data in WAL-directory databases (#419). It now only syncs the WAL there, and open replays every WAL file.
  • DurabilityMode::Adaptive never synced the WAL, so it behaved like NoSync, and backups missed writes made while they ran; those now land in this backup or the next.
  • Schema changes, constraints and create_graph() / drop_graph() were lost on WAL replay (#421, #422). An unknown WAL record now fails the open instead of being skipped.
  • DROP CONSTRAINT did nothing (#420). Constraints are now stored by name: CREATE and DROP CONSTRAINT support IF NOT EXISTS / IF EXISTS, SHOW CONSTRAINTS lists them, and unnamed ones get a name such as City_name_not_null. Constraints from 0.5.43 files have no name and cannot be dropped by name.
  • Reopening a .grafeo database or to_memory() lost its indexes (lookups on a reopened file scanned every node, #459), and to_memory() also dropped the schema, constraints and property history. WAL-directory databases still lose their indexes until 0.6.0 (#401).
  • Rust builds with only the rdf profile kept no data across a reopen (#544); the facade's rdf profile now includes the LPG store.
  • Embeddings changed while their vector index was spilled reverted on reload (#522).
  • Sessions and direct reads after compact() missed parts of the database: SPARQL writes were lost, CDC missed queries, the selected graph was ignored, and get_node, node_count, info(), schema() and similar did not see pre-compaction data (or panicked on a with_store database). Queries after compact() are still not written to the WAL, so a crash loses them; direct calls are logged.
  • Grants and read-only modes did not stop every write (#413): use_graph() skipped the grant check, read-only grants allowed writes, and read-only transactions, roles and databases let non-GQL languages and the direct API write. A session whose graph was dropped also fell back to the default graph; it now fails.
  • Transactions: a failed statement inside a transaction kept its partial writes (it is now undone, and the transaction goes on); a commit that failed with a write-write conflict left the transaction active (#409); a transaction could not delete what it had created; transactions in different named graphs raised false write conflicts; and rolled-back nodes still counted in the planner's statistics.
  • Writes that should fail went through: SET, REMOVE and label changes on a node or edge deleted earlier in the statement or transaction (now ... does not exist or has been deleted in this transaction, as in openCypher), writes from a session reading at an earlier epoch (set_viewing_epoch, execute_at_epoch), and writes after compact() on a database opened read-only. All of these now fail.
  • Writes skipped schema checks: SET, REMOVE, SET n:Label and MERGE did not check property types, NOT NULL, UNIQUE, NODE KEY, edge types, endpoint labels or DEFAULT values, UNIQUE did not hold within a transaction, and multi-property UNIQUE and NODE KEY were checked one property at a time. Every write is now checked like INSERT, and a failed ALTER NODE TYPE or ALTER EDGE TYPE applies all or none.
  • Parameterized queries skipped schema and constraint checks (#526), so execute(query, params) in Python and Node.js could insert a duplicate UNIQUE value. They now run exactly like the same statement with literals.
  • Parameters: a statement that used a parameter nobody supplied stored the text $e (INSERT (:P {e: $e})) or failed with an internal error, and now fails with Missing parameter: $e before it writes anything; unaliased columns were named after a parameter's value (RETURN $x gave a column 5) and are now named after the query text; and PROFILE was ignored when parameters were passed (#460).
  • Removed properties stayed visible as null without the temporal feature (as in the Python and Node.js packages); REMOVE n.p and SET n.p = NULL now remove the property.
  • Commit and rollback got slower as the database grew (#410): a one-node insert took 36 ms at 1M nodes. They now cost only as much as the change.
  • Slow paths on large databases: setting a property got slower with the node's property count (NodeRecord::props_count is now always 0), every hundredth commit scanned the whole store, and concurrent writes could deadlock with tiered-storage.
  • Lookups by id() or an indexed property scanned every node (#454, #356), once per row for keys like UNWIND $rows AS row MATCH (n {id: row.id}), and with a label (MATCH (n:File {id: 'x'})) every node of that label was collected. They now seek directly, also for IN lists and edge endpoints.
  • Regular expressions and LIKE patterns were compiled for every row (#458); each is now compiled once.
  • Nodes and edges came back as IDs, as 0, or read another entity's properties (#482): after ORDER BY, SKIP, LIMIT, DISTINCT, a later UNION branch, a second MATCH, OPTIONAL MATCH, EXISTS, CALL, UNWIND, grouping or a write, a returned edge could be 0, a node or edge a raw ID, and a property could come from the entity of the other kind with the same ID (MATCH (a)-[r]->(b) RETURN r ORDER BY r.w). collect() and group keys returned nodes and edges as IDs, nodes(p) and relationships(p) returned internal ids, UNWIND of them or of a function call after MATCH returned no rows, and EXCEPT and INTERSECT over nodes or edges returned the wrong rows. Nodes, edges and lists of them now keep their kind through every clause, also in Gremlin union(outE(), out()).
  • keys(r) of an edge returned null; it now lists the edge's keys, and keys() of a node or edge returns them sorted, like properties().
  • A variable used as both a node and an edge matched by a coincidence of IDs (MATCH (r) MATCH ()-[r]->()); it now fails with an error that names the variable.
  • ORDER BY over values of different types failed or depended on the input order: values of different types compared as equal, numbers among them and around NaN came out of order, lists and maps were not ordered, and such a sort (also in percentileCont and percentileDisc) could fail with does not correctly implement a total order (a PanicException in Python). ORDER BY now uses the openCypher total order: maps, lists, paths, temporal values, strings, booleans, numbers, then null; integers and floats compare exactly and NaN sorts after infinity.
  • Other ORDER BY fixes: GQL NULLS FIRST and NULLS LAST were reversed with DESC, RETURN * ORDER BY ... failed with Variable '*' not found in input, and sort keys could show up as extra columns (RETURN r AS e ORDER BY e.w returned an e_w column).
  • Patterns through variables bound earlier matched too much: a path back to an earlier node (MATCH (a)-->(b)-->(a)) returned every two-hop path instead of the cycles, and a pattern through a bound edge (MATCH ()-[r]->() MATCH (x)-[r]->(y)) matched every edge of its shape, also after WITH, in CALL subqueries, in stored procedures and after a shortest path. Both now use what the variable holds.
  • Variable-length edges: the edge variable held only the last edge, a property map was checked only on the last hop, and a one-hop quantifier (*1..1, *1, GQL {1,1}) bound a single edge, so size(rs) returned null. The variable is now the list of the path's edges.
  • Shortest-path searches returned pairs without a path and ignored hop bounds (#514). Unreachable pairs no longer get a row, ->+ finds the shortest cycle, and the path must fit the quantifier.
  • Clauses after a write or a WITH in one statement (#479, #480): a MATCH after a write missed the written data (and when it found nothing, the write did not run), and a GQL MATCH after WITH failed with Variable '...' not found in input. Clauses now read the rows the earlier ones pass on.
  • EXISTS and COUNT subqueries gave wrong answers (#543): they checked only a path's first edge (missing multi-hop matches and minimums like *2..), ignored the rest of their pattern (EXISTS { MATCH (a)-[:KNOWS]->(b), (c:Robot) } held without any Robot), used only their start node from the row (EXISTS { MATCH (b)-[:KNOWS]->(a) } held whenever b knew anyone), ignored a tie to the row through a value ({id: s.id}, an inner WHERE), could run in WHERE before their variables were bound, and counted edges the query could not see. Each row now gets its own answer from the whole pattern, and a null node makes EXISTS false and COUNT 0.
  • GQL subqueries: in EXISTS, COUNT and VALUE, each MATCH clause matched on its own; an EXISTS that starts with OPTIONAL MATCH filtered rows; VALUE { ... RETURN count(x) } ignored its argument and DISTINCT; and a VALUE subquery that is not a count gave every row the same answer, or failed in WHERE and WITH. They now behave as the same clauses do outside a subquery.
  • Cypher OPTIONAL MATCH at the start of a query and in EXISTS and COUNT failed; it now runs, with one row of nulls when nothing matches.
  • CALL subqueries saw the wrong outer variables: a GQL CALL did not see the outer row, so every row got every match; an importing WITH (in GQL, the first WITH of the body) ignored its WHERE and WITH a AS b imported the wrong value; and a Cypher CALL without an importing WITH read outer variables as null. A GQL CALL now sees every outer variable and its WITH is an ordinary one, a Cypher CALL sees those its importing WITH lists, and the scope clause CALL (a, b) { ... } (CALL (*), CALL ()), which failed to parse, names them in both languages.
  • Nodes and edges a CALL subquery returned were copies of their properties: a later MATCH from them failed or matched everything, and id(b) and type(r) were null. RETURN * in a CALL also returned nothing, and a two-edge chain in a CALL no rows. The subquery now passes on the nodes and edges themselves, and RETURN * returns the variables it binds.
  • Cypher CALL bodies rejected ORDER BY, SKIP, LIMIT, UNION, DELETE, MERGE, REMOVE, FOREACH and a nested CALL; they now run, as in openCypher (an ordered LIMIT gives the top rows per outer row). A GQL CALL body takes UNION, EXCEPT, INTERSECT and OTHERWISE.
  • An OPTIONAL MATCH condition that reads a variable bound before it dropped rows: in MATCH (a)-[:KNOWS]->(b) OPTIONAL MATCH (b)-[:KNOWS]->(c) WHERE c.age > a.age, a b whose matches all failed the condition lost its row instead of keeping it with nulls. The condition now decides which matches count, in GQL and Cypher.
  • MERGE bound only the first of several matches: with two (:Tag {k: 1}) nodes, MERGE (t:Tag {k: 1}) SET t.seen = true set one of them. As in openCypher, MERGE now binds every match, one row each, also for relationships and in upsert_edges.
  • GQL NEXT did not pass rows on: the statement after NEXT matched from every node and returned the first statement's columns too. It now reads the rows the one before returns, and only the last RETURN is the result.
  • CDC history mixed graphs: history(id) and changes_between returned the events of every graph's entity with that id. Each event now names its graph (graph, also in Python and Node.js), history reads the database's default graph or the session's current graph, and changes_between returns every graph's events.
  • A variable a WITH left out failed with an internal error (MATCH (a) WITH 1 AS x RETURN a.name); it now fails with Undefined variable 'a'.
  • A Cypher pattern comprehension inside a function call, an aggregate or a map projection failed (RETURN size([(a)-->(b) | b])); it now runs.
  • A transaction on a .grafeo file, a WAL directory or a compacted database did not see the type of an edge it had created until the commit.
  • List comprehensions, list predicates and reduce() lost items (#538) when the expression called a function, read a property of a relationships(p) item or used another row variable.
  • EXCEPT, INTERSECT and OTHERWISE did not check their branches' columns (#481), and SQL/PGQ checked none of its set operations; they now fail like UNION.
  • Cypher =~ matched any part of the string; it now has to match the whole string, as in openCypher.
  • Cypher and SQL/PGQ checked only the first label of some node patterns (#513), e.g. MATCH ()-[r]->(n:A:B).
  • GQL INSERT with unlabeled or already bound endpoints failed with Undefined variable or attached edges to the wrong node.
  • A LOAD CSV row returned whole was null instead of the row's map.
  • Times with UTC offsets compared inconsistently; they now compare by instant.
  • toString() returned an internal form for temporal values, lists and maps; it now gives ISO 8601 text and literal form.
  • Very long ^ chains or runs of signs or NOTs could overflow the parser stack.
  • Docs: fixed the Discord invite and the GQL constraint examples (#344).

Deriva

Changes for Deriva, which generates ArchiMate models from software repositories and keeps its graph in an embedded Grafeo database through the Python binding.

  • Deterministic Louvain: the same partition on every run, with communities numbered from 0 by their smallest node id.
  • Python: grafeo.features() and grafeo.build_info(): the compiled-in languages and capabilities, plus version, git commit and build profile.
  • Dotted access into map properties: n.meta.route (also chained) in GQL and Cypher; a missing key gives null.
  • The direct API follows the selected graph set with set_graph(), like execute().
  • Graph handles: db.graph(name) scopes queries, the direct API and transactions to one graph, usable side by side and across threads.
  • Direct writes are checked against the schema and constraints like INSERT, and advance the epoch, so changes_between and get_node_at_epoch see them in order.
  • CDC events say what changed: labels, edge type and endpoints, the last properties on delete, and one create event per entity created in a transaction.
  • Write counters: result.counters reports nodes, edges, properties and labels created, set or removed.
  • Upserts by key: upsert_nodes(labels, rows, key="id") and upsert_edges(...) create or update many entities by a key property and report created, updated and skipped rows.
  • shuffle_unordered test option: returns rows without ORDER BY in random order, to catch code that relies on an order that is not guaranteed.
  • Batch edges with properties, batch nodes with several labels (#462).
  • Cypher pattern predicates in WHERE: WHERE (d)-[:CONTAINS]->() and WHERE NOT (d)-->().

Internal

  • CI gate and policy checks (#511): a single CI Gate check to require before merging, and a Policy job for crate boundaries, toolchain pins and public-text rules.
  • CI toolchain pins restored (#509); Dependabot no longer bumps the Rust toolchain.
  • Pull request eligibility check: a PR Policy check applies the contribution rules in CONTRIBUTING.md.
  • Differential test: scripts/difftest runs a GQL and Cypher query corpus on the previous release and on a release build of a commit, and fails on any changed result that was not reviewed for the release; it also compares GQL with Cypher within one run.
  • Browser WASM size limit: the CI limit for the gzipped browser build is now 760 KB (warning at 740 KB); the build is about 730 KB after this release's fixes.

[0.5.43] - 2026-09-27

Stabilization release. Fixes for silent wrong results (ORDER BY + LIMIT, UNION, aggregates, duplicate column names, SPARQL named graphs and property paths), queries whose trailing statements were ignored, rollbacks that did not undo changes on persistent databases or SPARQL updates, databases over 4 GiB written corrupt, edges lost after compact() and HNSW vector updates, plus dependency and security updates.

Added

  • Gremlin negated text predicates: notRegex(), notContaining(), notStartingWith() and notEndingWith() in has() filters, e.g. g.V().has('city', notStartingWith('Am')). (#336, #340, @jakeboone02)

Changed

  • Breaking (Rust API): QueryResult::new, with_types and from_rows return Result and reject repeated column names (#371). Add ? or .unwrap() at call sites.
  • Unaliased columns are named after their expression, as written: RETURN id(a), n.a + 1, count(b) yields id(a), n.a + 1 and count(b) instead of id(...), expr and count(...). Aliased columns are unchanged. (#350, #372)
  • restore_to_epoch() refuses to overwrite an existing database or its .wal sidecar; restore to a fresh path instead. (#363, @teipsum)
  • Node.js: transaction queries run on a worker thread, like Database.execute(), so they no longer block the event loop. commit() and rollback() now throw while a query from the same transaction is still running.
  • Queries with trailing input are rejected (#380): GQL, Cypher and Gremlin fail with a syntax error on text after the statement, including ;-separated statements (a trailing ; is fine) and GQL clause orders not supported yet (e.g. SET ... DELETE). These used to run only the first part.
  • Breaking (Rust, grafeo-core): ColumnCodec::write_to, write_to_v2, write_to_v3 and CsrAdjacency::write_to return Result, failing instead of writing a truncated size (#392).
  • Rust toolchain pinned to 1.98.1 for local builds and CI. The MSRV stays 1.91.1, and the grafeo crate now declares it (#390).

Fixed

  • GQL ran only the first statement and ignored the rest (#380): INSERT ... INSERT ... (as in the quickstart) created only the first node and INSERT ... RETURN ignored its RETURN; both now work. Gremlin and Cypher had similar gaps.
  • GQL ^ (power) was not parsed: RETURN 2 ^ 10 returned 2. It now computes the power, binding tighter than * and right associative.
  • ALTER NODE TYPE / ALTER EDGE TYPE ... ADD PROPERTY name TYPE added a property called PROPERTY of type name and dropped the real type; DROP PROPERTY name dropped the wrong property. PROPERTY is now an optional keyword.
  • Databases over the storage format's limits were written corrupt (#392): an LPG section over 4 GiB or 65,535 blocks, or over 65,535 labels on one node or versions of one property, wrapped a size field, and the file failed to open with CRC mismatch. Such a checkpoint now fails with an error naming the limit and keeps the sidecar WAL, so nothing is lost. Larger sections are planned for 0.6.0.
  • Rolling back a transaction on a persistent database did not undo SET, REMOVE or label changes, and single-file databases wrote them to disk on close().
  • ORDER BY ... LIMIT over a whole-node RETURN returned raw NodeIds instead of node maps, both for property sort keys (#335) and for expression keys such as text_score(...), CASE or arithmetic (#347). (#337, #349, @temporaryfix)
  • Duplicate column names silently lost data (#371), e.g. RETURN id(s), id(t). Distinct expressions now get distinct names, and a result that would still repeat a name is an error asking for an alias; in SPARQL, an AS ?x that repeats a projected or bound name is a parse error. (#372, @teipsum; #350, @temporaryfix)
  • UNION with differing branches (#365): SPARQL now returns every variable from every branch, and GQL and Cypher reject branches with different columns instead of padding or truncating them. (#366, @teipsum)
  • Aggregates next to aliased or later items (GQL and Cypher): RETURN n.city AS city, count(n) failed with Undefined variable '_agg_0', columns came back in the wrong order, and unreturned GROUP BY keys appeared as extra columns.
  • SPARQL updates ignored open transactions: INSERT/DELETE ... WHERE applied immediately and was not undone by rollback(), and INSERT DATA / DELETE DATA on a named graph was dropped on commit. Updates now apply on commit, and queries in a transaction see its earlier writes.
  • SPARQL FROM and WITH <g> did not apply inside subqueries: a nested SELECT read the default graph instead of the query's dataset.
  • SPARQL updates on named graphs went to the wrong graph or were lost (#367): INSERT/DELETE ... WHERE with GRAPH <g> or WITH <g> now targets the named graph and survives a restart. USING / USING NAMED is rejected until it is supported. (#368, @teipsum)
  • SPARQL path+ returned only direct neighbours (#369); it now returns the full transitive closure. (#370, @teipsum)
  • Edges disappeared after compact() once an endpoint was modified (#345). Deleted edges also no longer reappear or show up as neighbours. (#346, @temporaryfix)
  • Updating an indexed vector could make other vectors unfindable (#374). Removing or replacing the HNSW entry point also no longer leaves the upper index layers unreachable. (#375, @jarmen423)
  • Filters on edge properties or map values returned no rows when nodes had a property with the same name, e.g. MATCH ()-[r]->() WHERE r.id = 'e1' or UNWIND [{id: 'a'}] AS m WHERE m.id = 'a', because they were answered from node statistics.
  • Property maps on anonymous edges were ignored: ()-[:T {since: 2020}]->() in Cypher and GQL matched every T edge.
  • Deleted nodes stayed in property and vector indexes, so find_nodes_by_property and vector search could still return them, and the lookup API returned uncommitted nodes. Rolling back a delete or SET now restores the index entries.
  • Computed property values in CREATE, INSERT and MERGE: CREATE (:X {id: toString(i)}) failed with an internal error, and MERGE silently used null, so every row matched or created the same node. They are now evaluated per row.
  • MERGE created duplicate relationships when several rows of one statement merged the same edge (UNWIND [1, 2] AS i MATCH (a), (b) MERGE (a)-[:T]->(b) created two). A null in a relationship's MERGE pattern now also matches an absent property, as it already did for nodes.
  • = and <> on dates, times, datetimes, durations and vectors were always false / true in expressions, e.g. RETURN date('2024-01-01') = date('2024-01-01'), x IN [date(...)] or a comparison after WITH. Filters answered directly from a node property were not affected.
  • Python: naive datetime values were read as local time but returned as UTC; they are now UTC both ways. Microseconds are no longer rounded and dates before 1970 work on Windows.
  • Storage docs: the .grafeo sidecar WAL is removed on a clean close(), not after every checkpoint. (#364, @teipsum)

Security

  • Fixed RUSTSEC-2026-0190 (anyhow), RUSTSEC-2026-0186 (memmap2), RUSTSEC-2026-0204 (crossbeam-epoch), RUSTSEC-2026-0258 (h2) and RUSTSEC-2026-0285 (rustls), and replaced two yanked crates (chacha20, der).
  • Python bindings on PyO3 0.29, fixing RUSTSEC-2026-0176 and RUSTSEC-2026-0177. No Python API changes.

Dependencies

  • Arrow and Parquet 59, aes-gcm 0.11, comfy-table 8, tikv-jemallocator 0.7 and hf-hub 1.0, among others (#396). The embed feature now downloads models through hf-hub's new client; models already in the Hugging Face cache are reused.

Thanks to @teipsum for six PRs and the detailed reports behind them, to @temporaryfix for #346, #349 and #350 and the root-cause analysis on #335, to @jakeboone02 for the Gremlin predicates, to @jarmen423 for the HNSW fix, and to @stiff, @halaharvi, @GanbaruTobi and @cuongvo for their reports. The filter, index and MERGE fixes come from reports by the Deriva project.

[0.5.42] - 2026-05-04

End-to-end tiered storage: section data (LPG, RDF Ring, vector topology) can spill to mmap-backed disk under memory pressure or explicit configuration, with per-section tier overrides, introspection, and reload. Plus per-block columnar zone maps for selective range scans, paged HNSW topology, packed RDF Ring on disk, a WAL overlay for mutating mmap'd compact stores and a streaming top-K operator that fuses ORDER BY ... LIMIT into a single bounded-heap pass.

Added

  • Per-section storage tier configuration: Config::with_section_tier(SectionType, TierOverride) pins individual sections to RAM or disk. ForceDisk spills at db open; ForceRam is hard-enforced (skipped in every spill loop). Auto (default) defers to the buffer manager.
  • db.storage_tiers() introspection: returns a map of section type to current tier (InMemory / OnDisk / Uninitialized). New MemoryConsumer::current_tier trait method backs it with authoritative state.
  • db.reload_eligible(target_fraction): brings spilled sections back into RAM in priority order, stopping when projected usage exceeds the target. Returns the count reloaded.
  • Python bindings for tier control: GrafeoDB(path, section_tiers={'VectorStore': 'force_disk'}), db.storage_tiers(), db.reload_eligible(). Eight new pytest cases.
  • Paged HNSW topology (vector v2): VectorStoreSection switches from bincode to a packed GVST envelope with per-index GTOP paged topology. MmapTopology serves neighbor lookups directly from Bytes slices; HnswIndex dispatches search through TopologyBackend { Heap, Mmap }. Heap drops >10x under mmap, recall is bit-identical.
  • Packed RDF Ring (ring v2): bincode RdfRingSection replaced by a GRFR envelope (sorted term dictionary + three packed wavelet trees + two permutations + CRC32). swap_to_mmap shares refcounted Bytes slices from the spill mmap.
  • Per-block columnar zone maps + iterator bounds: LPG compact-store columns lay out as 4 KiB blocks with per-block min/max. find_in_range_iter skips blocks via zone maps; RangeScanOperator streams matches into the push pipeline. Large speedups on selective range scans.
  • WAL overlay for mutable mmap'd LPG: when the compact base is OnDisk, mutations route to a live LpgStore overlay; periodic merge folds it back via LayeredStore::merge_overlay_in_place. New merge_guard makes concurrent reads + writes + merges race-free. Persistent file + spilled base + overlay mutations round-trip across restarts.
  • SectionType::OverlayDeletions: persists LayeredStore's deletion log so deletions of base entities survive close/reopen without an explicit compact(). Omitted from the container directory when empty. Marked required: false; meaningful once the directory parser learns to skip non-required unknowns (planned for 0.5.43).
  • tracing events on tier transitions (with tracing feature): info events under grafeo::buffer and grafeo::tier for spill, reload, and ForceDisk applications.
  • Storage Tiers documentation page: docs/architecture/memory/storage-tiers.md covering tiers, overrides, introspection, reload, and tracing conventions.
  • PageFetcher trait + MmapPageFetcher impl: indirection layer that lets sections receive paged byte access without knowing the source, opening a future swap to vmcache or an explicit pager.
  • Streaming top-K operator (TopKOperator): bounded-heap of size k, O(k) memory regardless of input cardinality, ~12x faster than the unfused Sort + Limit at N=1M. Planner fuses literal LIMIT k over Sort into a single physical operator; vector/text top-K still takes precedence and PROFILE-mode plans bypass the fusion for entry-count parity. (#326, @temporaryfix)
  • var.prop IN [literals] property-index fast path: per-value index lookups unioned via NodeListOperator, deduped, label/MVCC-filtered. ~56x speedup on WHERE id IN $ids against a snapshot-loaded database. (#326, @temporaryfix)
  • LogicalOperator::map_children: child-recursive optimizer passes descend without enumerating every variant.

Changed

  • Vector store and RDF Ring section formats bumped to v2 (paged GVST and packed GRFR envelopes). Existing v1 bincode files keep loading via magic-byte detection and upgrade to v2 on the next checkpoint.
  • TierOverride::ForceDisk enforcement is now targeted: previously triggered spill_all() for every consumer; now spill_consumer_by_name(...) per matching section, so unrelated consumers stay InMemory.
  • Filter pushdown extended through Filter, LeftJoin, Apply, Union, Unwind: predicates inside OPTIONAL MATCH and correlated subqueries now reach the scan. New pre-pushdown pass propagates filter predicates across LeftJoin shared-variable boundaries so the right-side subtree of an OPTIONAL MATCH picks up the same WHERE constraints as the main MATCH (~8x on feed.hydrate-shaped queries). Sibling Filter commute is gated by a variable-scope check so predicates over path variables like LENGTH(p) >= 1 don't push past a label filter into a subtree where the path isn't yet bound. (#326, @temporaryfix)

Fixed

  • MERGE ... ON CREATE/ON MATCH SET could not reference the MERGE variable (#317): expressions like ON MATCH SET c.description = coalesce(c.description, 'fallback') failed at the binder, and any non-trivial action expression that did pass through was silently lowered to Null. Binder now scopes the MERGE variable into ON CREATE / ON MATCH (per ISO/IEC 39075:2024 §15.5); planner emits a PropertySource::Expression for action expressions; operators evaluate them against an augmented row containing the merged entity. Reported by @Fraenkstan.
  • TierOverride::ForceRam was silently a no-op: prior versions accepted the config but the buffer manager spilled ForceRam consumers anyway under pressure. The spill loop now consults a force_ram_consumers set and skips matching consumers in run_eviction_internal, spill_all, and spill_consumer_by_name.
  • Persistent reopen lost the compact base after compact(): directory parser had no SectionType::CompactStore arm and load_from_sections had no handler, so reopens fell back to a legacy path that surfaced as a checksum mismatch. New extract_compact_base + wire_layered_after_load helpers rebuild the LayeredStore and consumer wiring on open.
  • Concurrent merge could lose writes: stress test with 4 readers + writer + merger lost 426/500 writes in one run. Fixed via merge_guard: RwLock<()> (mutators take read, merge takes write).
  • GRAFEO-X001: snapshot checksum mismatch opening v2 section files (#323, #324): read_snapshot was unconditionally a v1 reader and CRC'd zero bytes against the v2 directory CRC when the engine's open path fell through. Reader now early-returns on a v2 header (snapshot_length == 0 with non-empty header), letting the engine's v2 dispatch take over. Reported by @teipsum against an embedded production database.
  • Silent corruption masking in read_section_directory (#323 follow-up): the v2 directory parser swallowed from_bytes errors with a wildcard, routing truncated directories and torn-page writes into the v1 read logic and surfacing as a misleading snapshot-checksum error. Parser now propagates errors with the failing offset, treats "v2 header but file too short" as an error rather than a v1 fallback, and verifies the directory page CRC. Regression tests cover both paths.
  • LayeredStore deletions silently reverted on reopen (#323 follow-up): base nodes/edges deleted after compact() were tracked only in memory, so reopen made them reappear until the next compact(). New OverlayDeletions section persists the deletion log; open path seeds the layered store from it. Round-trip integration tests lock in the behaviour.
  • Per-query spill directory leak (#323 follow-up): <spill_path>/query_<id>/ directories were created per query and never removed; one production day accumulated 358 empty subdirectories. SpillManager/AsyncSpillManager now expose with_owned_dir() so Drop removes the directory non-recursively (preserving unexpected siblings).
  • active_db_header docstring drift: the function picks the higher-iteration slot unconditionally; checksum validation lives in the readers. Doc rewritten to match.
  • Equality-scan regression from the Bytes-backed codec refactor: BitPackedInts, DictionaryEncoding, and ColumnCodec::{Float64,RawI64} switched to a two-variant Inline(Vec<T>) / Mapped(Bytes) store so in-RAM builds keep native slice iteration and mmap loads stay zero-copy. Closes the 19-29% CodSpeed regression on compact/find_nodes_by_property/{int_eq,dict_eq}.
  • Property-index fast path silently disabled after compact(): LayeredStore::has_property_index now delegates to the overlay (was using the trait default false). Snapshot-loaded databases stop falling back to the ~3.5x-slower label-first scan. (#326, @temporaryfix)
  • ORDER BY against a WITH alias dropped by RETURN: e.g. WITH x AS s ... RETURN x ORDER BY s failed with Variable 's' not found because the augmented-projection path skipped Variable sort keys. They now pass through alongside Property keys. (#326, @temporaryfix)
  • PROFILE panicked on fused fast-path operators: property/range/IN-list paths absorbed their child NodeScan into one physical op, leaving build_profile_tree short an entry. Affected paths now emit a synthetic NodeScan entry; the factorized expand-chain fusion is also gated under PROFILE. (#326, @temporaryfix)

Deprecated

  • TieredStore trait (grafeo_common::memory::buffer::TieredStore): never implemented anywhere; the Section trait (with swap_to_mmap + reload_to_ram) plus MemoryConsumer cover the same lifecycle. Marked #[deprecated(since = "0.5.42")] and scheduled for removal in 0.6.0. The StorageTier enum it shipped alongside is kept.

Thanks to @teipsum (Michael Lewis Cram) for reporting #323 with byte-level analysis from a production embedded database, and for #324's read_snapshot fix which lands cherry-picked with authorship preserved. The follow-up work in this release (stricter read_section_directory validation, durable LayeredStore deletions via the new OverlayDeletions section, and the per-query spill directory cleanup) addresses the deeper root causes the original report surfaced.

Thanks to @temporaryfix for the top-K operator, IN-list fast path, LeftJoin filter propagation, and the cluster of fixes around compact() and PROFILE in #326, reshaped to fit grafeo's map_children recursion pattern before merging.

[0.5.41] - 2026-04-24

Compact-store correctness (post-compact() read path, signed integer round-trip), search procedures, disk-backed compact base, silent-hybrid-on-persistent-DB fix, memory introspection for RDF and CDC, and test-infrastructure hardening (proptest, persistent spec variants, CodSpeed CI).

Added

  • CALL grafeo.search.* procedures: first-class procedure entry points for text and vector search, with scalar similarity available as an expression in projections. Routes through the existing GraphStoreSearch surface so WAL/CDC wrappers and LayeredStore all work.
  • WASM transactions + close(): explicit transaction API for the browser bindings, plus close() to release handles deterministically instead of waiting for GC.
  • Tamper-evident WASM snapshots: exportSnapshotSigned(key) / importSnapshotSigned(data, key) wrap snapshots with a GSN1 magic header and an HMAC-SHA256 tag over magic + payload, with 128 MiB input cap and constant-time verification. importSnapshot() refuses GSN1-prefixed payloads so the two entry points can't be confused.
  • Property-based round-trip coverage for compact() (#303): proptest generates arbitrary LPG graphs and asserts equivalence across 28 GQL queries per case on a compacted vs fresh database. 128 cases by default, PROPTEST_CASES=1024 for local investigation. Surfaced the two compact-store bugs fixed in this release (#301, #302).
  • Persistent spec-test variants (#309): every .gtest case requiring text-index or vector-index now auto-generates a _persistent sibling that opens GrafeoDB::open(tempdir), exercising the WAL-wrapped read path used by every on-disk session. 49 persistent variants land with this release.
  • CodSpeed continuous benchmark regression (#304): all seven Criterion suites run under Callgrind on every PR via cargo codspeed, posting a diff vs main as a PR comment. Fork PRs skip cleanly; plain cargo bench continues to work unchanged.
  • ColumnCodec::RawI64 (#306): native signed 64-bit codec for columns containing at least one negative value, with i64 comparison in find_eq / find_in_range and signed zone maps. Non-negative columns continue to use the more compact BitPacked encoding.
  • wasm32 simd128 distance kernels (#305): std::arch::wasm32 implementations of all four HNSW distance metrics (dot product, squared Euclidean, cosine, Manhattan), 4.36x to 5.35x faster on 384-dim f32 vectors. Enabled by default for wasm32 builds via .cargo/config.toml (target-feature=+simd128); runtime requirement Chrome 91+ / Firefox 89+ / Safari 16.4+. Relaxed-simd FMA deliberately skipped: spec permits runtime-defined rounding, and wasmtime regresses vs plain add+mul.
  • RDF and CDC memory breakdown: db.memory_usage() gains rdf (triple count, term dictionary, optional Ring index, named-graph count) and cdc (entity count, event count) blocks alongside the existing store/index/MVCC/cache totals. Feature-gated and skipped from JSON when empty.
  • CLI :memory meta-command: prints the hierarchical memory breakdown in the REPL, omitting zero-valued and feature-disabled components.
  • Disk-backed compact base (compact-store + mmap features): CompactStoreTiered wraps the columnar base in a two-state InMemory / OnDisk (mmap) machine. compact() registers a CompactStoreConsumer with the BufferManager; under memory pressure the base serialises to <spill_path>/compact_base.grafeo and ArcSwap publishes the fresh mmap-backed Arc through LayeredStore, so queries see no discontinuity and the old heap allocation drops.
  • Contributor docs for CDC, query planner, and MVCC visibility: module-level //! guides covering the CDC event model and epoch relationship, the planner's rewrite and filter-pushdown ordering, and the EpochId::PENDING / visibility / TransactionWriteTracker flow.

Changed

  • On-disk codec format extended: databases written by 0.5.41+ may contain columns under the new RawI64 discriminant (6). Earlier 0.5.4x binaries reject these with unknown codec discriminant. One-way format break, in line with the 0.5.35 precedent.

Fixed

  • Post-compact() writes invisible to MATCH (#307, closes #302): LayeredStore get/versioned/epoch/property/type/visibility methods now fall through to the overlay when the base doesn't recognise the id; edges_from / neighbors always consult the overlay so cross-layer edges surface. Results dedup by EdgeId to handle promoted edges.
  • Signed Int64 columns stringified after compact() (#306, closes #301): negative-containing Int64 columns were routed to InferredType::Dict, silently becoming Value::String (WHERE n.num = 100 returned zero rows). Signed columns now use the new RawI64 codec, preserving type and ordering through compaction.
  • Silent text/vector search on file-backed DBs (#309, closes #308): WalGraphStore and CdcGraphStore fell through to the GraphStoreSearch trait defaults (all no-ops), so every index lookup on a GrafeoDB::open() session silently returned "no index." Both wrappers now delegate every GraphStoreSearch method to self.inner.
  • Cypher aggregate substitution inside CASE WHEN (#300): aggregates wrapped in CASE WHEN ... THEN sum(...) ... now substitute the reduced value post-aggregation instead of leaving an unresolved reference.
  • VectorScan k=None risked HNSW overflow (#299 follow-up): unbounded k was being passed as usize::MAX, degrading HNSW to full traversal and risking overflow in quantized rescore. The planner now bounds k to the label's node count via a new nodes_by_label_count trait method.
  • Native SIMD kernels read past b on mismatched slice lengths (#312, closes #311): AVX2, SSE, and NEON kernels drove the main loop from a.len() and raw-pointer-loaded b, guarded only by a debug_assert_eq!. The four public *_simd dispatchers now assert length equality in release.
  • rustls-webpki 0.103.13: clears RUSTSEC-2026-0104 (reachable panic in CRL parsing), pulled transitively via hf-hub -> ureq -> rustls.
  • GQL schema DDL: CREATE GRAPH TYPE bare references no longer corrupt the catalog (#316): NODE TYPE Person and EDGE TYPE KNOWS inside a graph-type body are now treated as references to existing catalog entries per ISO/IEC 39075:2024, not as empty inline redeclarations. The previous behavior silently wiped NOT NULL and other property constraints on the referenced types. References to undefined types now error cleanly at CREATE GRAPH TYPE time.

Dependencies

  • proptest 1.x added as workspace dev-dep and enabled on grafeo-engine for property-based tests.
  • codspeed-criterion-compat 3.x added as workspace dev-dep and enabled on grafeo-common, grafeo-core, grafeo-storage, grafeo-engine. Drop-in for Criterion; pass-through outside cargo codspeed run.
  • tempfile added as a dev-dep on grafeo-spec-tests for the _persistent variants.
  • arc-swap 1.x added to the workspace and enabled on grafeo-core under the compact-store feature, backing the LayeredStore base pointer for lock-free atomic swap.

Thanks to @temporaryfix for substantial work this cycle: the two compact-store correctness fixes (#306, #307) and the property-based suite that surfaced them (#303), the hybrid-on-persistent fix (#309), the Cypher CASE aggregate fix (#300), the VectorScan k bounding follow-up to #299, the native SIMD safety assert (#312, closes #311), CodSpeed benchmark CI (#304), and the wasm32 simd128 distance kernels (#305).

[0.5.40] - 2026-04-20

Unified hybrid queries (graph + vector + text), lazy streaming results, structured Python errors, catalog hierarchy hardening, and compact-store fixes.

Added

  • Unified hybrid queries: text_score() and text_match() usable as filter expressions, with planner pushdown of score predicates to TextScan / VectorScan operators, compound AND/OR joins, top-K recognition, and score projection. Inspired by #287 (@temporaryfix); reimplemented via the GraphStoreSearch subtrait.
  • BM25 text scan operator: TextScanOperator with top-K and threshold modes. InvertedIndex gains score_document, search_with_threshold, bm25_term_score. (#287, @temporaryfix)
  • Native Float64 and Float32Vector codecs: CompactStore stores them directly instead of falling back to dictionary encoding. Mixed Int64+Float64 columns coalesce to Float64. (#286, @temporaryfix)
  • Streaming query results (experimental): Session::execute_streaming returns a ResultStream that pulls one DataChunk at a time, bounded memory regardless of result-set size. Exposed across bindings: Python execute_lazy(), Node.js executeStream(), C# ExecuteStream(), Dart executeStream(), and Go and C FFI equivalents. Rejects mutations, EXPLAIN/PROFILE, session commands, and push-only plans.
  • Python GrafeoError exception: subclass of RuntimeError carrying error_code ("GRAFEO-Q001") and is_retryable. Legacy except RuntimeError: paths keep working.
  • Error codes reference: user-guide page documenting every GRAFEO-* code, retry semantics, and a Python retry-loop sample.
  • Catalog hierarchy docs (ISO/IEC 39075): user-guide page covering schemas, named graphs, session state, isolation, and cross-schema transactions.

Changed

  • DatabaseStats.memory_bytes reflects the full heap breakdown: now equals memory_usage().total_bytes (store + indexes + MVCC + caches + string pools + buffer manager) instead of just buffer-manager-tracked bytes.
  • Schema and graph names reject /: CREATE SCHEMA / CREATE GRAPH now fail on names containing /, which Grafeo uses internally as the compound schema/graph storage-key separator.

Fixed

  • Multi-schema transaction atomicity: SESSION SET SCHEMA mid-transaction no longer loses pre-switch writes on COMMIT. Fix centralizes "touched graph" tracking inside the session setters so every active-key change is recorded.
  • Commit failure auto-rollback: a failed COMMIT now discards pending writes and returns the session to a clean state, instead of leaving the transaction in-flight with uncommitted writes still visible.
  • Parser keyword anti-pattern: try_accept_keyword helpers fix four CREATE CONSTRAINT ... FOR, ON REPLACE, and similar sites where identifier-fallback tokenization accepted the wrong keyword.
  • Vector strict pushdown boundary leak: euclidean_distance(...) < t and manhattan_distance(...) < t now correctly exclude rows at exactly the threshold; strict comparisons attach a residual filter above the vector scan, which was previously dropped.
  • MERGE index lookup: MERGE (n:Label {prop: value}) now uses property indexes when available, eliminating O(n) scan on large graphs. (#288)
  • Index and search after compact(): ~26 vector/text index methods no longer panic with "no built-in LpgStore" or silently return empty results. (#286, @temporaryfix)
  • LayeredStore new-node visibility: get_node / get_node_property fall back to the overlay for nodes added after compact(). (#286)
  • Named graphs across compact() / recompact(): graphs existing before compaction are carried into the new overlay; list_graphs, drop_graph, create_graph, set_current_graph see them.
  • Layered scan lock holding: nodes_by_label acquires dirty_node_ids once per scan instead of re-locking in the chunk loop. (#278, @temporaryfix)

[0.5.39] - 2026-04-16

Block-STM conflict partitioning, push-based query execution, AES-256-GCM encryption at rest, runtime metrics with Prometheus export, and a writable layered compact store.

Added

  • Block-STM conflict partitioning: groups conflicting transactions into disjoint clusters for parallel re-execution.
  • Encryption at rest (encryption feature): AES-256-GCM for WAL records and .grafeo sections. Password-based (Argon2id) or raw-key setup. Zero overhead when disabled.
  • Push-based pipeline execution: filter, sort, aggregate, limit, and distinct queries execute through a push pipeline, reducing per-row overhead.
  • Runtime metrics (metrics feature): query, transaction, session, cache, and GC counters with Prometheus text export. Python db.metrics() / db.metrics_prometheus() and Node.js equivalents.
  • C# enterprise APIs: schema management, backup/restore, compact, projections, CDC toggle, plan cache. IGrafeoDB and ITransaction interfaces for DI.
  • Resource limits: default 30-second query timeout (Config::with_query_timeout()), 16 MiB property value size limit (max_property_size), HNSW max_elements bound.
  • Layered store (compact-store feature): compact() produces a writable two-layer store (columnar base + overlay) instead of a read-only snapshot. recompact() merges the overlay back. Versioned section format with CRC32 integrity and ID-preserving builds.
  • WAL benchmarks: Criterion benchmarks for write throughput, batch commit, and recovery replay.

Changed

  • compact() is now non-destructive: creates a writable layered store instead of converting to read-only mode.
  • Cast clippy lints re-enabled: cast_possible_truncation, cast_sign_loss, cast_possible_wrap promoted to warn workspace-wide.
  • Leaner WASM builds: removed grafeo-storage, crc32fast, anyhow from WASM targets. Binary size: 650 KB gzipped. CI threshold: 660 KB warn, 700 KB fail.
  • Expand locality optimization: sorts input chunks by source node ID before adjacency lookups on large traversals.
  • CI hardening: MSRV verification (1.91.1), typos check, supply-chain audit as required status check, benchmark regression gating for core paths, Node.js matrix reduced to 22/24.
  • Release pipeline hardening: explicit publish errors, pre-publish version consistency gate. Removed stale deny.toml skips, updated rustls-webpki (RUSTSEC-2026-0098).

Fixed

  • SSI validation race: concurrent commits could miss read-write conflicts due to a gap between state update and epoch recording. Both locks now held atomically.
  • Transaction lock ordering: consistent write-lock ordering in commit() and gc(), eliminating a potential deadlock from the previous read-then-upgrade pattern.
  • EXPLAIN/PROFILE nesting bypass: recursive EXPLAIN/PROFILE in GQL and Cypher now counts toward the 128-level nesting limit.
  • Session commit atomicity: touched_graphs clone-then-clear replaced with atomic mem::take().
  • WAL encryption nonces: fixed reuse on restart, collisions across sections, and u64-to-u32 truncation. Old log file now fsynced before rotation.
  • HKDF key derivation: added domain-separation salt, preventing cross-protocol key reuse.
  • Parser overflow hardening: integer overflow in Cypher, SQL/PGQ, Gremlin, GraphQL now returns errors instead of silently producing 0. Float overflow (1e999) in GraphQL rejected.
  • Numeric cast safety: arena allocator, block serializer, buffer manager, DPccp optimizer, temporal constructors, and toInteger() all use checked arithmetic instead of unchecked casts.
  • DPccp join optimizer: fixed BitSet overflow for 64+ relations, added 100K iteration budget to prevent stall on large joins.
  • DISTINCT hash collisions: content-based hashing for List, Map, Vector, and Path values.
  • Parameters in subqueries: $param inside EXISTS/COUNT/VALUE subqueries now substituted correctly. (@temporaryfix)
  • SHACL SPARQL injection: IRI validation prevents breakout via crafted $this values.
  • CDC history without permission: now requires RBAC read permission.
  • SIMD and arena checks: promoted debug-only vector length and alignment validation to release builds.
  • Buffer manager TOCTOU race: replaced non-atomic check-then-allocate with compare_exchange loop.
  • RDF dictionary panic: graceful error on u32::MAX entry overflow instead of panic.
  • Windows memory detection: reads actual physical memory instead of falling back to 1 GB.
  • Python execute_sql language mismatch: standardized to "sql" across all bindings.
  • Gremlin range(5, 2) overflow: returns error instead of panic when end < start.

[0.5.38] - 2026-04-13

Hardening, ISO compliance, and vector search improvements driven by persona-based exploratory testing. Parser security limits prevent stack overflow attacks, all six query languages gain EXPLAIN support, Unicode identifiers bring GQL closer to ISO 39075, and quantized vector indexes cut memory usage up to 4x for large embedding workloads.

Breaking: QueryBuilder.param() now raises ValueError on unsupported types instead of silently dropping them: code that relied on silent fallthrough will see exceptions and should fix the type conversion. Parser error messages now include a language prefix (e.g., [GQL] Unexpected token): code that pattern-matches on error strings may need updating. SPARQL SERVICE clauses now return an explicit error instead of silently executing the inner pattern against the local store: queries that appeared to work but returned incorrect results will now fail with a clear message. SPARQL property path +/* expansion depth raised from 10 to 50: queries on deep hierarchies will return more complete results, which may increase result set sizes and execution time.

Added

  • Quantized vector indexes: create_vector_index() accepts quantization parameter ("scalar", "binary", "product") for 4x memory reduction on large vector datasets. VectorIndexKind enum unifies plain and quantized indexes throughout the engine. All bindings (Python, Node.js, WASM, C) updated.
  • EXPLAIN/PROFILE for all 6 query languages: Gremlin, GraphQL, and SQL/PGQ now support EXPLAIN and EXPLAIN ANALYZE prefix, matching existing GQL, Cypher, and SPARQL support. Python explain(), explain_cypher(), explain_sql(), explain_gremlin() convenience methods added.
  • Unicode identifiers: GQL, Cypher, and SQL/PGQ parsers now accept Unicode letters in identifiers (e.g., CREATE (:人物 {名前: 'Alix'})), per ISO GQL 39075. Gremlin and GraphQL already supported this.
  • Unicode string escapes: \uXXXX (4-digit BMP) and \UXXXXXXXX (8-digit full range) escape sequences in string literals across all query languages.
  • CONSTRUCT output serialization: Python QueryResult.to_ntriples() and to_turtle() methods for SPARQL CONSTRUCT results.
  • NetworkX ID round-tripping: from_networkx() preserves original node IDs via _networkx_id property, to_networkx() restores them. Returns a node mapping dict.
  • GQL != operator: accepted as alias for <> (not-equal comparison).

Changed

  • Parser error messages now identify the language: all 6 parsers prefix errors with [GQL], [Cypher], [SPARQL], [Gremlin], [GraphQL], [SQL/PGQ].
  • SPARQL property path depth raised to 50: +/* paths now expand to 50 hops (up from 10), covering most real-world taxonomies and org charts.
  • QueryBuilder.param() raises ValueError: previously silently dropped unsupported types, now raises with a descriptive message.
  • execute_async() documentation: docstring now explains that it uses spawn_blocking (releases GIL, uses thread pool, not truly non-blocking I/O).

Fixed

  • Parser recursion depth limits: all 6 parsers now enforce a 128-level nesting limit, preventing stack overflow on deeply nested malicious input (DoS vector).
  • SPARQL SERVICE clause returned wrong results silently: now returns an explicit error instead of executing the inner pattern locally.
  • GQL integer overflow produced confusing errors: overflow on integer literals now reports the value and valid i64 range.
  • NetworkX in_degree() was O(V*E): replaced full-graph scan with direct adjacency index lookup.
  • AsyncQueryResult missing nodes()/edges(): entity extraction now runs post-spawn_blocking, matching sync QueryResult.
  • RDF blank node collisions across imports: Turtle parser now prefixes blank node IDs per-import (_:imp{N}_b0), preventing cross-file collisions.
  • Incremental backup always failed after full backup (#267): the backup cursor stored the active WAL file's sequence without rotating, so post-backup writes stayed invisible to incremental. Both backup_full and backup_incremental now rotate the WAL after completing, ensuring new writes land in a file the next incremental will pick up.
  • Edge variables in multi-hop queries returned as raw IDs (#268): plan_expand_chain and plan_factorized_aggregate did not register edge columns in the planner's tracking set, causing RETURN to emit NodeResolve instead of EdgeResolve. Edge variables now resolve to full maps with _id, _type, _source, _target, and properties.
  • Arrow/DataFrame export dropped user properties named source/target/id/type (#272): structural columns in edges_to_arrow(), edges_df(), nodes_to_arrow(), and nodes_df() collided with user property names, silently dropping them. Structural columns are now underscore-prefixed (_id, _type, _source, _target, _labels) to match the engine's edge_to_map()/node_to_map() convention. Breaking: code referencing df["source"] must change to df["_source"].
  • Weighted hybrid search inverted vector ranking: hybrid_search() with fusion="weighted" applied min-max normalization to raw vector distances, causing the farthest node to score highest. Vector distances are now negated before fusion so that closer vectors rank higher.

Documentation

  • Search score conventions: new table in the Vector Search guide clarifying return value semantics across all search methods (vector_search returns distances, lower = better; hybrid_search returns fusion scores, higher = better; mmr_search returns distances in MMR selection order; text_search returns BM25 scores, higher = better).
  • Text Search guide: new dedicated page covering BM25 index creation, searching, auto-sync behavior, and when to rebuild.
  • Hybrid Search guide: new dedicated page covering RRF vs weighted fusion, prerequisites, graceful degradation, and the common pitfall of treating fusion scores as distances.
  • MMR Search guide: new dedicated page covering Maximal Marginal Relevance parameters, lambda tuning, and when to use MMR vs vector search.
  • Index auto-sync clarified: rebuild_vector_index() and rebuild_text_index() docs now explain that indexes auto-sync on set_node_property() and batch operations; explicit rebuild is rarely needed. Updated across Rust doc comments, Python/Node.js binding docstrings, and API reference pages.

[0.5.37] - 2026-04-12

RDF Semantic Web overhaul with improved SPARQL support, RDF performance improvements and SHACL validation.

Added

  • SPARQL compliance pass: spec gaps closed. CONSTRUCT, BIND, OPTIONAL, MINUS, UNION, FILTER, EXISTS/NOT EXISTS. Named graph CRUD and SPARQL UPDATE. Composite indexes (SP, PO, OS) for O(1) multi-bound lookups. new W3C tests.
  • Ring Index planner (ring-index): wavelet-tree compact triple index wired into SPARQL planner. Leapfrog WCOJ for multi-way star joins, hash join fallback when LANG/DATATYPE columns needed.
  • Ring Index persistence: bincode serialization with post-load invariant validation. RdfRingSection persists to .grafeo container, eliminating rebuild on restart.
  • Dictionary encoding infrastructure: TermDictionary maps terms to u32 IDs. DictResolveOperator resolves at result boundaries. Built lazily, invalidated on mutation.
  • COUNT(*) fast paths: O(1) for unbound scans via store.len(), O(log sigma) for bound patterns via Ring Index.
  • RDF query optimizer: per-predicate cardinality estimates, cached statistics, cost-based join reordering.
  • SPARQL EXPLAIN / EXPLAIN ANALYZE: physical plan tree without executing, or profiled execution with per-operator timing. Python explain_sparql() binding.
  • SHACL validation (shacl): W3C Shapes Constraint Language with all 28 Core constraint types, SHACL-SPARQL (sh:sparql), 7 property path types with cycle detection, ValidationReport with to_triples() RDF materialization. session.validate_shacl(shapes_graph) in Rust, db.validate_shacl("graph") in Python. In rdf persona and server profiles.
  • Arrow bulk export (#260): nodes_to_arrow()/edges_to_arrow() (pyarrow Table), nodes_to_polars()/edges_to_polars() (Polars DataFrame), nodes_to_pandas()/edges_to_pandas() (pandas DataFrame via Arrow). Builds RecordBatch in Rust, serializes to IPC: ~10-100x faster than per-element nodes_df()/edges_df() at scale. Existing nodes_df()/edges_df() auto-use the Arrow fast path when pyarrow is available.

Changed

  • RDF store indexes upgraded to foldhash: replaced ahash with foldhash::fast::RandomState for all RDF HashMap indexes.
  • TxId renamed to TransactionId: consistent naming across the codebase.

Fixed

  • Incremental backup could skip WAL records: backup cursor was not advanced, causing duplicate replay on restore (#258).
  • File manager leaked temp files on checkpoint failure: temp files now cleaned up in the error path (#258).

[0.5.36] - 2026-04-11

Authentication at engine level with RBAC, per graph access grants and several query language improvements.

Added

  • Role-based access control: Identity, Role (Admin, ReadWrite, ReadOnly), and StatementKind types for scoping sessions to specific permission levels. db.session_with_identity(identity) creates a session bound to an identity, db.session_with_role(role) is a convenience shorthand. Permission checks run after parsing but before execution across all query languages (GQL, Cypher, Gremlin, GraphQL, SQL/PGQ, SPARQL). No credentials or crypto at this layer: the caller is trusted to assign the correct role.
  • Graph projections: read-only filtered views of a graph store via ProjectionSpec and GraphProjection. Filter by node labels and edge types to create virtual subgraphs for algorithms and queries. Manage with create_projection()/drop_projection()/list_projections() in Rust, Python, Node.js, WASM, and C. GQL syntax: CREATE PROJECTION name LABELS (...) EDGE_TYPES (...), DROP PROJECTION name, SHOW PROJECTIONS.
  • Gremlin repeat().times()/.emit(): parse and execute repeat(out()).times(n) for fixed-depth traversal and repeat(out()).emit() for all-depths traversal. Maps to the existing VariableLengthExpand operator. until() predicates, path(), simplePath(), and loops() remain pending.
  • CSV/JSON Lines import: CLI grafeo import csv/grafeo import jsonl commands, Python import_csv()/import_jsonl(), Node.js importCsv()/importJsonl().
  • Per-graph access grants: Grant type scopes an identity's access to specific named graphs. Identity::with_grants([Grant::new("social", Role::ReadWrite)]) restricts access to listed graphs only. USE GRAPH, CREATE GRAPH, DROP GRAPH enforce grants when present. Empty grants = unrestricted (backward compatible).

Changed

  • Unified aggregate accumulator: push-based aggregate operator now uses the same AggregateState as the pull-based operator, gaining support for all 30+ aggregate functions (COLLECT, LAST, STDEV, percentiles, regression, etc.) that previously returned NULL in push mode.
  • session_read_only() deprecated: use session_with_role(Role::ReadOnly) instead. The old method remains as an alias.

Fixed

  • Release workflow missing grafeo-storage: the crate publish sequence now includes grafeo-storage before grafeo-engine, fixing cascading publish failures.
  • Permission bypass in parameterized queries: _with_params methods used a text heuristic to gate write permissions, which had false negatives for languages like GraphQL. Restricted identities now use plan-based mutation detection.
  • Projection neighbors() ignored edge-type filter: neighbors connected via excluded edge types were incorrectly returned.
  • Projection edge_type() leaked hidden edges: edges whose endpoints were excluded by label filtering could still have their type queried.
  • Spill serialization dropped DISTINCT semantics: DISTINCT aggregate variants are now serialized via finalized-value fallback to avoid corrupting results after reload.
  • Gremlin times() accepted negative values: negative loop counts silently wrapped to huge values, now returns a parse error.
  • Gremlin nested repeat modifiers: .times()/.until()/.emit() now work inside union(), coalesce(), and other nested traversals.
  • Projections retained stale store after compact(): compact() now clears all projections to prevent stale data and memory leaks.
  • rand RUSTSEC-2026-0097: updated to 0.10.1.

[0.5.35] - 2026-04-11

Breaking: QueryResult.rows is now private (use rows()/into_rows()), all public enums are #[non_exhaustive] (add _ => arms), old feature profiles (embedded, browser, server, full) are deprecated in favor of persona-based profiles (lpg, rdf, analytics, ai, edge, enterprise) and the on-disk storage format changed from bincode blobs to block-based sections (databases created with 0.5.34 or earlier must be re-created).

Added

  • Persona-based feature profiles: new named profiles lpg, rdf, analytics, ai, edge, enterprise describe use cases instead of deployment targets. Compose them freely: features = ["lpg", "ai"] for a graph app with search, features = ["rdf", "analytics"] for a knowledge graph with algorithms. Old profiles (embedded, browser, server, full) remain as deprecated aliases with unchanged behavior, scheduled for removal in 0.7.0.
  • Python named graph management: create_graph(), drop_graph(), list_graphs(), set_graph()/reset_graph()/current_graph(), set_schema()/reset_schema()/current_schema() (#241, #243 by @Michaelzag)
  • Python per-transaction CDC override: begin_transaction_with_cdc(True|False) (#242, #244 by @Michaelzag)
  • Arrow IPC export (arrow-export): zero-copy export to Arrow IPC for DuckDB, Polars, pandas, DataFusion interop
  • GEXF + GraphML export: graph interchange for Gephi, Cytoscape, NetworkX, yEd, igraph. CLI --export-format gexf|graphml
  • Section-based container format: .grafeo files use a section directory with checksummed, independently addressable sections. Checkpoint writes only dirty sections, recovery loads in parallel.
  • grafeo-storage crate: persistence I/O extracted from grafeo-adapters. grafeo-core and grafeo-storage are now siblings (both depend only on grafeo-common).
  • Unified flush model: checkpoint, CHECKPOINT, and memory-pressure eviction share one code path
  • Regression + memory benchmarks: 11 Criterion benchmarks with per-benchmark thresholds, 5 memory benchmarks with CI bounds checking
  • Section serializers: Vector Store (HNSW topology) and Text Index (BM25 postings) persist to container, eliminating index rebuild on open
  • Per-section memory config: SectionMemoryConfig with max_ram caps and TierOverride per section type
  • Mmap for index sections: zero-copy read via memmap2, CRC-verified, cross-platform lifecycle
  • BufferManager section consumers: sections register as MemoryConsumers for accurate pressure tracking
  • Periodic checkpoint timer: background flush at Config::checkpoint_interval, bounds WAL size
  • Container format spec: docs/architecture/storage/container-format.md
  • Vector spill to disk: vector columns drain to MmapStorage under memory pressure, search reads transparently from mmap
  • BufferManager spill integration: eviction calls spill() on consumers after in-memory eviction exhausted
  • PropertyColumn eviction: drain_values(), evict_values(), restore_values() with spilled flag
  • Block-based LPG section format (v2): replaces bincode blob with a structured layout: string table, packed node/edge arrays, columnar property blocks, label assignments, per-block CRC. Enables mmap for data sections.
  • Block-based RDF section format (v2): replaces bincode with string-table-deduplicated triple storage, per-block CRC, named graph sub-sections.
  • WAL overlay: in-memory mutation layer (WalOverlay) for tracking inserts, updates, deletes on top of mmap'd base data. Supports drain/clear for checkpoint merge.
  • TieredStore trait: StorageTier enum (InMemory, OnDisk, Uninitialized) and TieredStore trait defining persist(), open_mmap(), reload_to_ram() lifecycle in grafeo-common.
  • CDC retention and eviction: CdcRetentionConfig with max_epochs and max_events limits. CdcLog implements MemoryConsumer for BufferManager-driven eviction. Pruning hooks into MVCC GC cycle. (#250)
  • EpochAdvance WAL record: new WalRecord::EpochAdvance { epoch } logged after each TransactionCommit. is_metadata() trait method on WalEntry. Enables epoch-bounded WAL replay for point-in-time recovery.
  • Incremental backup: backup_full(), backup_incremental(), restore_to_epoch() on GrafeoDB. Backup manifest tracks the chain, backup cursor in WAL directory prevents premature log truncation. CLI commands: grafeo backup full, grafeo backup incremental, grafeo backup status, grafeo backup restore-to-epoch. Exposed in Python, Node.js, and C bindings.

Changed

  • VersionChain uses Vec instead of VecDeque: eliminates 4-slot minimum allocation per entity, reducing per-entity memory 14-31% (#251)
  • Schema DDL types decoupled from GQL parser: shared types (SchemaStatement, PropertyDefinition, etc.) moved to query::schema module, allowing Cypher to compile without the gql feature (#234)
  • QueryResult.rows is now private: use rows() for borrowed access, into_rows() for ownership, push_row()/from_rows() for construction
  • #[non_exhaustive] on 95 public enums: downstream match must add _ => wildcard arms
  • Python abi3 wheels: single wheel per platform supports Python 3.12+
  • rdf feature renamed to triple-store: deprecated rdf alias kept for one release, rdf now names the persona profile
  • lpg feature flag: LPG model is now explicit in grafeo-core/engine/adapters, symmetric to triple-store
  • Crate restructure: storage backends moved to grafeo-storage, grafeo-adapters is parser-only, grafeo-core/src/storage/ renamed to codec/
  • Removed tokio from grafeo-core: async spill moved to grafeo-engine/src/execution/spill/
  • CI: benchmark job adds per-benchmark thresholds and baseline persistence on main

Fixed

  • WAL not replayed on reopen: data written via mutations was lost across restarts when no explicit checkpoint was called. Session commits now log TransactionCommit + EpochAdvance to WAL, and the close path only removes the sidecar WAL after verifying the checkpoint wrote data. (#252)
  • CDC event log unbounded memory: the CDC log stored every mutation with full property snapshots and never pruned. Added epoch-based and count-based retention (CdcRetentionConfig), integrated with BufferManager for memory-pressure eviction. (#250)
  • VecDeque::first_mut compile error: fixed to front_mut() in MVCC VersionChain (tiered-storage feature path)
  • Graph/schema context validation: reject nonexistent targets, drop_graph() auto-clears active context (#245, #246 by @Michaelzag)
  • C binding grafeo_reset_schema: propagates errors instead of silently discarding
  • WASM setSchema: returns proper JS Error instead of plain string
  • CI benchmark --save-baseline: scoped to Criterion crates only
  • Aggregate grouping hash collision: wildcard arm now hashes std::mem::discriminant to distinguish future Value variants
  • edges_df()/nodes_df() column overwrite: properties named after structural columns (source, target, type, id, labels) no longer silently replace them (#254)

[0.5.34] - 2026-04-07

Pre-RC hardening: query engine fixes from external integration testing, format stability, feature matrix CI.

Added

  • GQL schema hierarchy (ISO/IEC 39075 Section 4.2.5): CREATE SCHEMA/DROP SCHEMA, SESSION SET SCHEMA, full data isolation between schemas
  • Streaming RDF triple sink: TripleSink trait with BatchInsertSink (bounded memory) and CountSink (dry-run)
  • Streaming Turtle/N-Triples load: load_turtle_streaming(), load_ntriples_streaming() insert incrementally
  • Golden fixture tests: snapshot v4, .grafeo file format, and WAL frame backward-read + byte-equality checks
  • Deterministic snapshot export: nodes/edges/labels/properties sorted by ID/name for reproducible exports
  • Feature matrix CI: per-profile build+test jobs (gql-only, gql+vector, gql+rdf, embedded, browser)
  • Serialization benchmarks: snapshot export/import and Value bincode round-trip

Changed

  • LabelRegistry combined lock: merged label_to_id + id_to_label into one RwLock<LabelRegistry>, reducing write-path lock acquisitions
  • #[non_exhaustive] on 13 public enums: future variants can be added without breaking semver
  • missing_errors_doc/missing_panics_doc lints enabled: public functions now document error/panic conditions
  • MVCC types hidden: VersionChain/VersionInfo re-exports marked #[doc(hidden)]

Fixed

  • WAL sync counter race: fetch_sub after sync instead of store(0), preserving concurrent increments
  • Multi-aggregate extraction: sum(a) + count(b) now extracts all aggregates
  • Mixed WITH ... WHERE/HAVING: non-aggregate conjuncts stay as WHERE, aggregate parts become HAVING
  • references_any completeness: all LogicalExpression variants handled
  • CREATE SCHEMA duplicate WAL record: only logged when graph is actually created
  • BatchInsertSink zero batch_size: defensive max(1) clamp
  • delete_node_edges self-loop: deduped via HashSet, batch edge lock, batch adjacency lock
  • cypher feature needing gql dependency (#232, #233 by @Michaelzag)
  • Node.js napi cfg-gated methods: moved methods into per-feature #[napi] impl blocks
  • BitPackedInts::from_bytes: bits_per_value > 64 now returns Err instead of panicking
  • WAL log_files: directory read errors now propagated instead of swallowed
  • Per-feature compilation: missing cfg gates across vector-index, rdf, mmap, regex
  • Algorithm unreachable panics: expect("node in index") replaced with enumerate/let-else in functions
  • Null property pattern matching: MATCH (n {key: null}) now matches nodes where the property is absent or explicitly null (MERGE and MATCH)
  • MERGE null key matching: MERGE (n:T {a: null, b: 'x'}) correctly finds existing nodes with absent/null a
  • SET += {key: null} removes property: SET n += {price: null} now removes the property instead of keeping it as null
  • Negative LIMIT clamped to 0: LIMIT -1 returns empty result instead of raising a syntax error (GQL and Cypher)
  • i64 MIN literal parsing: -9223372036854775808 now parses correctly by folding -<integer> at parse time (GQL and Cypher)
  • NaN/Inf float literals: NaN, Inf, Infinity recognized as IEEE 754 special float values (GQL and Cypher)
  • nodes(p) resolves to property maps: nodes(path) now returns node maps with properties instead of raw Int64 IDs, enabling [n IN nodes(p) | n.name]
  • Cyclic VLP pattern matching: MATCH p=(s)-[:R*]->(s) now filters expanded targets to match the source node via id() equality
  • VLP default depth raised: unbounded [*] now expands up to min_hops + 100 (was +10)

[0.5.33] - 2026-04-05

GraphChallenge benchmark suite, RDF-to-LPG bridge, and a large round of query engine correctness fixes.

Added

  • GraphChallenge algorithms (DARPA/MIT IEEE HPEC 2026): k-truss decomposition, parallel triangle counting, subgraph isomorphism (VF2), stochastic block partition, partition quality metrics (Rand index, NMI, precision, recall)
  • TSV/MMIO bulk import: import_tsv(), import_mmio(), import_tsv_rdf() for fast graph loading bypassing per-edge transaction overhead
  • RdfGraphStoreAdapter: bridges RdfStore to GraphStore, giving RDF graphs access to all graph algorithms
  • grafeo-cli PyPI publish workflow (#222)

Fixed

  • CompactStore multi-table edge types: same edge type across multiple label pairs now produces separate RelTables. Added rel_tables_for_type() (#221, #225 by @Imaclean74)
  • WAL deadlock on property mutations: store mutation now applied before WAL logging, matching lock ordering of create/delete methods
  • GQL CREATE INDEX ... FOR parsing: FOR accepted whether lexed as keyword or identifier
  • round()/floor()/ceil(): float inputs return Float64 instead of truncating to Int64
  • CALL ... YIELD with aggregation: aggregates now work over procedure results
  • Cypher keyword-as-label: Order, By, Skip, Limit usable as node labels
  • CompactStore edge type statistics: counts aggregated across multiple rel tables
  • CAST(bool AS INT): true casts to 1, false to 0
  • List + concatenation: [1, 2] + [3, 4] returns [1, 2, 3, 4]
  • Parameter substitution in multi-statement queries: $param variables now forwarded to intermediate statements
  • ORDER BY + LIMIT/SKIP: SKIP and LIMIT now apply after ORDER BY
  • MIN/MAX aggregate output type: uses Any instead of Int64, fixing coercion for Float64 and Date values
  • Cypher ORDER BY after aggregation: property references resolve correctly after GROUP BY
  • JOIN column deduplication: multi-pattern MATCH with shared variables no longer produces duplicate columns
  • SET self-reference: SET n.value = n.value + 1 pre-computes expressions before the property write
  • size(collect()) nested aggregate: no longer panics during aggregate extraction
  • WITH ... WHERE on aggregate alias: WHERE predicate correctly promoted to HAVING
  • SUM() on empty result set: returns null per ISO GQL

Performance

  • Triangle counting: oriented adjacency built directly from GraphStore, improving cache efficiency on CSR-backed stores
  • WAL sync_all() outside lock: reduces lock contention under concurrent writes
  • Kahan compensated summation: sum() uses Kahan algorithm to reduce floating-point rounding errors

[0.5.32] - 2026-04-03

Correctness hardening, Jepsen readiness, and Hybrid Logical Clock for causal consistency.

Added

  • GrafeoDB::compact(): converts a live database to a read-only CompactStore in one call. Available as db.compact() in Python, Node.js, WASM; grafeo_compact(db) in C. Included in embedded and browser profiles by default (#199)
  • Hybrid Logical Clock (HLC): HlcTimestamp packs physical ms (48-bit) + logical counter (16-bit) into a u64 with lock-free CAS for monotonic timestamps. Replaces wall-clock SystemTime::now() in CDC events
  • CDC for session mutations: CdcGraphStore decorator buffers CDC events during transactions, flushes on commit (discards on rollback). Session-driven mutations via GQL/Cypher now generate CDC events
  • Session CRUD methods: set_node_property(), set_edge_property(), delete_node(), delete_edge(), create_edge_with_props() on Session for transaction-aware direct mutations
  • Gremlin valueMap() and elementMap() with no arguments: returns all properties (or id + label + all properties) as a map
  • Stress and crash tests: WAL-disabled crash injection, concurrent MERGE, mixed read/write contention, concurrent schema mutations, and 5 epoch monotonicity stress tests for CDC
  • Expanded gtest suite: 4 real-world datasets (e-commerce, movies, IT infrastructure, transportation), gap tests for all languages (GQL, Cypher, Gremlin, SQL/PGQ, SPARQL), Rosetta cross-language fidelity, production coverage (data type round-trips, mutation patterns, input validation), parameter substitution, catalog diagnostics, index correctness, temporal queries, and algorithm tests (Dijkstra, PageRank, centrality, BFS, SCC)

Fixed

  • Sibling CALL block scope collision: same-named variables in sibling CALL blocks no longer clobber each other (#213)
  • GROUP BY hash collisions: hash_value() now uses discriminant tags for all Value variants, preventing cross-type collisions; added Date, Time, Timestamp, Duration, ZonedDatetime, Bytes, Map variants to GroupKeyPart
  • Cypher ORDER BY zeros with relationship traversal: planner now resolves to the existing projected column instead of returning zeros (#218)

Changed

  • CDC is now opt-in per session: no longer unconditionally active when compiled in. Config::with_cdc() and GrafeoDB::set_cdc_enabled() control the default (off). Fixes +251% regression on single-node inserts. Python: GrafeoDB(cdc=True). Node.js: db.enableCdc(). C: grafeo_set_cdc_enabled(db, true)
  • CompactStore native codec scans: find_eq() and find_in_range() push checks into the codec's native domain instead of decoding to Value per row. Thanks to @temporaryfix (#216)

Internal

  • SPARQL ORDER BY STR() tests tightened: removed error-accepting fallback; NullGraphStore is correct for expression evaluation
  • Vector search $ne/$nin NULL semantics: documented and regression-tested (SQL three-valued NULL semantics)

[0.5.31] - 2026-04-01

CompactStore: a read-optimized columnar graph store for memory-constrained environments. Thanks to @temporaryfix for the design, prototype and implementation (#199, #204). Also, all remaining syntax gaps covered by the gtest suite are now fully implemented!

Added

  • compact-store feature flag: opt-in columnar read-only store for WASM, edge workers and embedded devices. Per-label NodeTables with typed columns, double-indexed CsrAdjacency for O(degree) traversal, zone-map skip optimization, and a fluent CompactStoreBuilder API with build-time validation. Integrates via GrafeoDB::with_read_store(Arc<dyn GraphStore>), all query languages work through it
  • Benchmark: compact_benches criterion group with nodes_by_label, get_node_property, and edges_from benchmarks for CompactStore
  • execute_language(language, query, params) in Python and Node.js bindings: generic dispatch for non-standard language keys (e.g. "graphql-rdf") without needing dedicated methods
  • SQL/PGQ UNION, INTERSECT, EXCEPT: full set operation support between GRAPH_TABLE queries, with optional ALL modifier
  • GraphQL multiple root fields and variable substitution: { person { name } company { name } } now translates all root fields via Union instead of dropping all but the first; $variable references emit LogicalExpression::Parameter with default value propagation from query declarations
  • Binding spec runner params: support: Python, Node.js, Go, and C# test runners now pass gtest params: fields to parameterized execution methods
  • DatabaseInfo.features: db.info() now returns a features array listing all compiled feature flags (e.g. ["gql", "cypher", "algos", "vector-index"]), available in all bindings (Python, Node.js, WASM, C, Go, C#, Dart)
  • WASM lpg and rdf build profiles: two new named profiles join browser and full. lpg bundles all LPG query languages plus AI search; rdf bundles SPARQL and GraphQL over the RDF model

Fixed

  • GQL list slice and path search: [1..3], [..2], [3..] slices now work (one-char lexer bug); MATCH ANY p = ... and MATCH p = ANY SHORTEST ... path search prefixes now use the existing shortest-path BFS operator
  • SPARQL pattern matching: MINUS with disjoint variables returns left side unchanged per spec; <p>*/<p>? property paths include zero-length reflexive match; VALUES with UNDEF produces correct partial bindings; anonymous blank node [] as subject expanded correctly
  • SPARQL function and type evaluation: STRLEN, CONCAT, IF, COALESCE, arithmetic work in SELECT/BIND projections; STRDT() produces typed values; DATATYPE() companion columns track original XSD types through scans; subquery aggregation propagates to outer queries
  • SPARQL graph management: GRAPH ?g scans only named graphs per spec 13.3; FROM/FROM NAMED restrict visible graphs per spec 13.1-13.2; CLEAR ALL clears both default and named graphs; DESCRIBE returns Concise Bounded Description
  • SPARQL updates and literals: DELETE { ... } WHERE { ... FILTER(...) } applies the filter correctly; language-tagged literal comparison checks both value and tag
  • Gremlin traversal fixes: multi-hop dead end no longer causes "Column not found"; values() with no keys returns all properties; scalar values in union branches no longer coerced to NodeId(0); path() on empty traversal returns empty result set
  • Cypher CREATE INDEX / DROP INDEX / SHOW INDEXES: indexes now registered in the catalog, persisting across statements
  • GraphQL aggregation: personCount, personAggregate { sum_age }, and _count field patterns now emit proper aggregate operators
  • RDF GraphQL: per-test language: graphql-rdf dispatch for mutation rejection testing; first/limit/skip/offset pagination in the RDF translator

Performance

  • RDF schema type propagation: plan_operator threads concrete LogicalTypes through the entire plan tree instead of LogicalType::Any, keeping triple scan data in Vec<ArcStr> (8 bytes/entry) through joins, sorts, and projections instead of Vec<Value> (40 bytes/entry)

Internal

  • Spec runner feature detection: all 6 binding spec runners (Python, Node.js, WASM, C#, Dart, Go) now use db.info().features to detect available capabilities instead of probing for individual methods, eliminating false skips for non-language features like algos and vector-index
  • ValueVector push safety net: type-mismatched pushes now fall back to VectorData::Generic instead of silently dropping data
  • derive_rdf_schema removed: replaced by concrete type propagation through plan_operator return values
  • eval_function split: 1,687-line monolith refactored into a thin dispatcher and 9 focused category methods
  • Dedup macros and utilities: impl_algorithm! for GraphAlgorithm boilerplate (17 of 23 implementations), map_common_keywords! for shared lexer keyword mapping, unescape_string extracted to shared module, extract_and_map generic for binding entity extraction

[0.5.30] - 2026-03-30

Async storage foundation and continued test coverage. Thanks to @maxwellflitton for the async storage adapter discussion that shaped this release.

Added

  • async-storage feature flag: new opt-in feature for async WAL and storage operations, included in server profile
  • AsyncTypedWal<R>: type-safe async WAL wrapper mirroring sync TypedWal<R>, with identical on-disk format for cross-recovery compatibility
  • AsyncLpgWal: type alias for AsyncTypedWal<WalRecord>, the async equivalent of LpgWal
  • AsyncWalManager::write_frame: extracted low-level frame writer enabling generic WalEntry types in async context
  • AsyncWalGraphStore: async decorator that logs mutations to AsyncLpgWal before applying to LpgStore, with named graph context tracking via tokio mutex
  • GrafeoDB::async_wal_checkpoint(): async WAL checkpoint via spawn_blocking, avoids blocking the tokio runtime during fsync
  • GrafeoDB::async_write_snapshot(): async snapshot write via spawn_blocking for .grafeo single-file format
  • AsyncStorageBackend trait: object-safe async trait for pluggable persistence backends (WAL batches, snapshots, sync), enabling community implementations for Postgres, S3, etc.
  • AsyncLocalBackend: built-in local filesystem implementation wrapping AsyncLpgWal
  • SnapshotMetadata: metadata type for snapshot listing in async backends
  • Node.js walCheckpoint() and save(): new sync methods for checkpoint and persistence in Node.js bindings

Fixed

  • 86 stale spec test skips removed: path modes (TRAIL, SIMPLE, ACYCLIC, WALK), ALL SHORTEST search prefix, list slice syntax, SPARQL string/datetime/hash functions, RDF term construction, conditional functions, named graphs, property paths, GraphQL directive evaluation, and more
  • SPARQL dateTime extraction functions: YEAR, MONTH, DAY, HOURS, MINUTES, SECONDS, TIMEZONE, TZ now correctly parse typed xsd:dateTime literals with timezone offsets
  • SPARQL LANGMATCHES(): implemented RFC 4647 basic filtering with case-insensitive prefix matching and wildcard "*" support
  • SPARQL LANG() companion columns: language tags are now tracked through triple scans and available to LANG()/LANGMATCHES() in FILTER
  • SQL/PGQ parameters in WHERE: $name, $min_age parameter references now resolved in filter evaluation via gtest runner wiring
  • SQL/PGQ HAVING inline aggregates: HAVING COUNT(*) > 0 and other inline aggregates in HAVING clauses now correctly extracted and referenced
  • SQL/PGQ zero-length paths: *0..N variable-length patterns now emit the source node as a 0-hop match
  • Cypher collect(DISTINCT ...): size(collect(DISTINCT n.v)) now correctly extracts the wrapped aggregate through non-aggregate function calls

[0.5.29] - 2026-03-29

Query engine correctness improvements and unified declarative test suite.

Added

  • Turtle parser and serializer: zero-dependency W3C Turtle support (load_turtle(), to_turtle() on RdfStore), with prefix detection, subject grouping, numeric/boolean shorthands, a shorthand, and line/column error positions
  • N-Quads serializer: to_nquads() on RdfStore for exporting default and named graphs in a single stream
  • Declarative .gtest spec test framework: new grafeo-spec-tests crate with a YAML-based test format, build.rs code generator, and runtime comparison library. 2500+ tests across all 7 language/model combinations (GQL, Cypher, Gremlin, GraphQL (LPG+RDF), SQL/PGQ, SPARQL and Rosetta cross-language) from a single source of truth, with runners for binding-level verification
  • EXISTS subquery in RETURN: RETURN EXISTS { MATCH (n)-[:R]->(:Label) } AS flag now works for single-hop correlated patterns, including label-filtered endpoints
  • Aggregate detection in GQL WITH: WITH count(n) AS cnt, max(n.val) AS mx now correctly produces an aggregate operator instead of treating aggregates as scalar expressions

Changed

  • Adjacency list memory: replaced SmallVec<8> with Vec (struct 256 to ~144 bytes), added auto-compaction in add_edge() to fix unbounded delta buffer growth

Fixed

  • Integer arithmetic overflow: 9223372036854775807 + 1 no longer panics; checked arithmetic returns NULL on overflow (SQL semantics) for all operations (+, -, *, /, %, unary negation)
  • Label intersection across MATCH clauses: MATCH (n:A) MATCH (n:B) now correctly filters to nodes with both labels instead of ignoring the second label constraint
  • CASE WHEN with NULL aggregate: WITH count(c) AS cc RETURN CASE WHEN cc = 0 THEN 0 ELSE ... END no longer returns NULL when the WHEN branch is true
  • EXISTS with property filters: EXISTS { (n)-[:R]->(m) WHERE m.age > 30 } silently dropped the WHERE, matching all connected nodes
  • Keywords as property names: {order: 3} and n.order rejected order and other keywords in property contexts
  • Gremlin hasLabel on edges: g.E().hasLabel('KNOWS') returned 0 rows because the translator used node labels instead of edge type
  • Gremlin parser: added regex() predicate, $param parameters, mid-traversal V() step, bare label/id keywords in by() modifiers
  • Gremlin coalesce() semantics: now uses OtherwiseOp for first-non-empty branch selection instead of Union which returned all branches
  • Gremlin group().by() two-pass: group().by(key).by(value) now correctly sets grouping key and value projection, with MapCollect wrapping for single-map output
  • Gremlin optional() step: rewrote translation to produce correct per-row semantics (navigation vs filter cases) instead of returning identity vertex
  • Gremlin values() null filtering: values('nonexistent') now returns zero rows instead of a row with null, matching Gremlin semantics
  • Gremlin addE with as() labels: from('a') / to('a') now resolves step labels from the as() alias map instead of treating them as literal strings
  • Gremlin or() three-valued logic: or(hasLabel('X'), has('prop', val)) across different node types now correctly returns matches from both branches (NULL OR true = true)
  • SPARQL functions in SELECT projections: created RdfProjectOperator that delegates to RdfExpressionPredicate for full function support (STRLEN, UCASE, LCASE, IF, COALESCE, REPLACE, etc.)
  • SPARQL IN/NOT IN operators: added FilterExpression::List evaluation and BinaryFilterOp::In handling in RdfExpressionPredicate
  • SPARQL BOUND() with OPTIONAL: checks vector validity bitmap directly to distinguish unbound variables from null values after LEFT JOIN
  • SQL/PGQ unbounded variable-length paths: *1.. no longer silently caps max_hops to 1
  • SQL/PGQ COUNT(column) NULL skipping: COUNT(expr) now uses CountNonNull to skip NULL values per SQL standard
  • SQL/PGQ CASE expressions: CASE WHEN in outer SELECT and WHERE clauses now evaluated by the translator
  • SQL/PGQ outer SELECT projection: non-aggregate SELECT col FROM GRAPH_TABLE(... COLUMNS(...)) now projects the correct columns
  • SQL/PGQ ORDER BY on aggregate aliases: ORDER BY for aggregate queries now placed after the Aggregate operator so output aliases resolve correctly
  • JSON Infinity/NaN lost through C FFI: SUM() overflow returned null in bindings because JSON cannot represent infinity; now encoded as string "Infinity"
  • C#/Dart temporal values: dates, times, and durations returned as locale-dependent native types instead of ISO strings
  • Binding spec runners: replaced YAML library parsers (Go yaml.v3, C# YamlDotNet, Dart package:yaml) with line-based parsers matching Rust/Node.js/Python; fixed SPARQL dispatch, hash assertions, error test logic, WASM feature gating

[0.5.28] - 2026-03-27

Hotfix: single-file .grafeo storage was silently disabled in all bindings.

Fixed

  • Single-file storage broken in bindings (#185): grafeo-file feature was missing from the embedded profile, causing grafeo_open_single_file and .grafeo auto-detection to silently fall back to WAL directory format. Added grafeo-file to engine defaults, embedded profile, and all binding crates (C, Python, Node.js, facade)

[0.5.27] - 2026-03-27

C FFI overhaul, Dart expansion, binding-wide usability audit, grafeo-memory engine support.

Added

  • C API overhaul (#185): grafeo_open_single_file, _with_params for all 5 languages, unified grafeo_execute_language, type-safe GrafeoIsolationLevel enum
  • Dart bindings expansion: openSingleFile, openReadOnly, executeLanguage, execute*WithParams, schema context, property/vector indexes, batchCreateNodes
  • Dart Flutter guide: native library bundling for Windows, macOS, Linux desktop
  • Go bindings: OpenSingleFile, ExecuteLanguage, Execute*WithParams, ExecuteParams(map[string]any)
  • Rust facade re-exports: Error, Result, QueryResult now at crate root
  • batch_create_nodes_with_props: engine + Python method accepting list of property dicts with mixed types including vectors
  • Temporal property versioning API (temporal feature): get_node_property_at_epoch, get_node_property_history, get_all_node_property_history
  • Node.js user guide: 5 pages covering database, queries, CRUD, transactions, results
  • C# P/Invoke completeness: 11 missing native declarations added, Transaction.ExecuteLanguage() with async variant
  • Crash safety testing: new crash injection point, 6 new recovery/concurrency tests
  • Python API docs: 45+ undocumented methods added to API reference (DataFrame, batch, search, algorithms, temporal, admin)

Fixed

  • labels(n)/type(r) in aggregation (#187): complex expressions in GROUP BY and ORDER BY failed with "Cannot resolve expression to column". Fixed in all 4 planner locations (LPG aggregate, LPG sort, RDF aggregate, RDF sort)
  • C# isolation level always failed: P/Invoke passed string where int expected. Added IsolationLevel enum
  • C# DropVectorIndex threw on success: now returns bool
  • C# P/Invoke mismatches (3): wrong signatures for property indexes, create_vector_index, batch_create_nodes
  • C# double-rollback after commit: TransactionHandle now skips rollback when committed
  • Go stale grafeo.h: 40+ missing declarations prevented compilation
  • Go column order random: replaced map iteration with ordered JSON key parsing
  • Go thread-local error race: added runtime.LockOSThread() around all C calls (including GetNodeLabels, HasPropertyIndex)
  • Node.js stale TypeScript definitions: 6 missing methods, improved rows() type
  • Dart iOS loader: missing Platform.isIOS branch
  • Dart Duration decoding: returned raw ISO string instead of Duration object
  • Rust docs (8 errors): wrong method names, nonexistent APIs, incorrect fallibility
  • SPARQL docs contradicted themselves: two pages said "not supported" while it works
  • README missing pip install grafeo: added as primary install command
  • WASM docs: createVectorIndex wrongly listed as unavailable
  • Vector search filter optimization: operator filters ($gt, $lt, etc.) now scan only the narrowed allowlist instead of all nodes
  • Single-file storage silent failure (#185): no file created when WAL disabled
  • C API grafeo_current_schema memory leak: returned caller-owned pointer but docs said not to free; now uses thread-local storage
  • C API out_count uninitialized on error: vector_search, mmr_search, batch_create_nodes, and find_nodes_by_property now zero all output pointers (out_count, out_ids, out_distances) before the main operation
  • Windows read-only file ops failure: skipped sync_all() on read-only handles in both close() and sync()
  • Adjacency inline capacity: raised SmallVec from 4 to 8, balancing L1 cache residency with fewer heap allocations for typical node degrees
  • ORDER BY complex expressions leaked columns: RETURN n.name ORDER BY labels(n)[0] included a synthetic __expr_ column in results. Complex ORDER BY expressions are now computed inside the augmented Return and stripped after sorting
  • GROUP BY on list-valued keys: GROUP BY labels(n) on multi-label nodes produced extra rows because GroupKeyPart lacked a List variant. Added recursive List(Vec<GroupKeyPart>) with proper Hash/Eq, and fixed push-based aggregator hash_value() which mapped all lists to 0u8
  • SPARQL GROUP BY/ORDER BY with expressions: GROUP BY (STR(?s)) and ORDER BY ASC(STR(?s)) failed with "Store required for expression evaluation". RDF planner now passes a NullGraphStore to ProjectOperator for expression evaluation

[0.5.26] - 2026-03-25

GQL conformance validation, SQL/PGQ features, and a big batch of bug fixes.

Added

  • GQL conformance (ISO/IEC 39075:2024): 234-query corpus cross-validated against GraphGlot. All 24 identified gaps closed: post-edge quantifiers (->{1,3}, ->+, ->*), path alternation (|, |+|), FILTER WHERE, SELECT...FROM...MATCH, brace-delimited graph types, and per-pattern path search prefixes
  • SQL/PGQ: WHERE inside GRAPH_TABLE, SELECT DISTINCT, GROUP BY / HAVING, and graph name references
  • Cross-language correctness tests: SQL/PGQ queries validated against GQL equivalents, plus CALL block scope isolation tests

Fixed

  • EXISTS/COUNT subquery bugs: target-side correlation (#173) now flips traversal direction instead of looking up the anonymous source, end-node labels are verified at runtime (were silently ignored), and complex EXISTS inside OR predicates works via split semi-join + filter
  • WAL directory-format data loss (#174): close() wrote checkpoint metadata that caused recovery to skip older WAL files, silently losing pre-rotation data
  • UNWIND variable in SET clause (#172): five mutation planner functions assigned LogicalType::Node to pass-through columns, silently dropping Map values from UNWIND. All now use LogicalType::Any. Present since 0.5.14
  • SET n:Label drops variable binding (#178, #182): label operators discarded input columns, breaking any subsequent clause referencing the same variable. Now preserves columns per-row
  • Missing expression functions (#179, #180): timestamp() returns epoch milliseconds (was null), startNode(r)/endNode(r) return node IDs (were unimplemented), zero-argument temporal functions now work in SET clauses
  • CREATE after MATCH creates phantom nodes (#181): planner now skips node creation when the variable is already bound from a prior MATCH
  • SQL/PGQ GROUP BY silently dropped non-aggregate columns; C API typed entity access (#177) now returns explicit element_type/id/labels/type fields in JSON

[0.5.25] - 2026-03-25

RDF change tracking, CRDT counters, and tracing goes opt-in.

Added

  • RDF CDC bridge (cdc + rdf): SPARQL INSERT/DELETE mutations now emit ChangeEvent records to the CDC log, carrying N-Triples-encoded terms. Surfaces RDF changes through GET /changes and POST /sync for offline-first clients
  • CDC structural metadata: node Create events now carry labels, edge Create events carry edge_type/src_id/dst_id, giving sync clients everything needed to replay creates remotely
  • CRDT counter values: Value::GCounter and Value::OnCounter as first-class types with proper merge semantics (per-replica max). All bindings surface them as structured JSON objects

Changed

  • Tracing is now opt-in (tracing feature): compiles to zero-cost no-ops when disabled. Included in server profile, excluded from embedded/browser. Eliminates ~29% overhead on micro-benchmarks

Fixed

  • Cypher target node property filter ignored: MATCH ()-[r]->(o {name: 'X'}) returned unfiltered results. Translator now applies target and edge property predicates after expand (Discussion #155)
  • Schema isolation for types: SHOW/CREATE/DROP/ALTER type commands now respect SESSION SET SCHEMA. DROP SCHEMA rejects non-empty schemas (#167)
  • CREATE GRAPH TYPED regression: type name resolution now works correctly with session schemas, including cross-schema references like my_schema.type_name
  • Schema context in bindings: all bindings now expose set_schema/reset_schema/current_schema methods that persist across execute() calls
  • Temporal feature overhead: optimized VersionLog::at() with O(1) fast path for current-epoch reads, eliminated double HashMap lookups. Reduces overhead from ~16% to ~6%

[0.5.24] - 2026-03-24

Temporal properties, read-only mode, and snapshot format v4.

Added

  • Index metadata in snapshots: property, vector, and text index definitions now persist in v4 snapshots and auto-rebuild on import/restore
  • Read-only open mode: GrafeoDB::open_read_only() uses shared file locks for concurrent reads; mutations rejected at the session level
  • Agent memory migration tests: Rust and Python integration tests for HNSW at scale, BYOV 384-dim vectors, persistence, concurrent reads, bulk import, and storage size (Discussion #155)
  • Temporal properties (temporal feature): opt-in append-only property versioning with execute_at_epoch(), get_node_at_epoch()/get_node_history() APIs, snapshot roundtrip, and transaction-safe rollback (Discussion #163)

Breaking

  • Snapshot format v4: properties stored as version-history lists; not backward-compatible

Fixed

  • MERGE + UNWIND creates only one node: planner evaluated MERGE property expressions as constants at plan time, dropping UNWIND variable references. Now uses per-row resolution
  • MERGE with NULL node reference: OPTIONAL MATCH (n:NonExistent) MERGE (n)-[:R]->(m) silently succeeded as a no-op. Now returns a clear type mismatch error

[0.5.23] - 2026-03-23

Prometheus metrics, tracing spans, and SQL/PGQ optional matching.

Added

  • Prometheus metrics export (metrics): MetricsRegistry::to_prometheus() renders counters, gauges, and histograms in Prometheus text format; GrafeoDB::metrics_prometheus() for one-call access; plan cache stats merged into snapshots
  • Tracing spans: structured spans on query and transaction lifecycle (session::execute, query::parse/optimize/plan/execute, tx::begin/commit/rollback); zero-cost when no subscriber is registered
  • SQL/PGQ LEFT OUTER JOIN: LEFT [OUTER] JOIN MATCH and OPTIONAL MATCH inside GRAPH_TABLE(...), producing NULL-padded rows for unmatched patterns

Changed

  • Read-only expand fast path: all expand operators skip versioned MVCC lookups for read-only queries, using cheaper epoch-only visibility checks

Fixed

  • Questioned edge (->?) row preservation: LeftJoin collapsed source rows instead of preserving them with NULLs
  • Negative numeric literals in property maps: unary negation (e.g. {lat: -6.248}) now folds correctly at plan time for both GQL and Cypher (#160)

[0.5.22] - 2026-03-14

Pretty printing, observability, RDF performance overhaul, and GQL conformance tracking.

Added

  • Pretty-printed query results: QueryResult now renders as an ASCII table via Display, replacing the raw Vec<Vec<Value>> output
  • Observability (metrics): lock-free MetricsRegistry with atomic counters and fixed-bucket histograms, tracking queries, latency (p50/p99), errors, transactions, sessions, GC sweeps, and plan cache stats across all 6 query languages. Zero overhead when disabled
  • Edge visibility fast path: is_edge_visible_at_epoch() skips full edge construction when only checking MVCC visibility
  • Plan cache bindings: clear_plan_cache() in Python, Node.js, C, and WASM
  • RDF bulk load: bulk_load() builds all indexes in a single pass; load_ntriples() parses N-Triples with full term support (IRIs, blank nodes, typed/language-tagged literals)
  • SPARQL EXPLAIN: returns the optimized logical plan tree without executing
  • GQL conformance tracking: // ISO: test annotations linking to ISO/IEC 39075:2024 feature IDs, with scripts/gql-conformance.py for coverage reports and a machine-readable gql-dialect.json (community feedback)
  • GQL binary set functions (GF11): 12 statistical aggregates (COVAR_SAMP/POP, CORR, REGR_SLOPE/INTERCEPT/R2/COUNT/SXX/SYY/SXY/AVGX/AVGY)

Changed

  • RDF query performance: O(N*M) nested loop joins replaced with O(N+M) hash joins for all join types; composite indexes (SP, PO, OS) for O(1) lookup on 2-bound triple patterns; SPARQL optimizer uses RDF-specific statistics
  • Unsafe code enforcement: #![forbid(unsafe_code)] on pure-safe crates, #![deny(unsafe_code)] on crates with targeted unsafe
  • GroupKeyPart zero-alloc: uses ArcStr instead of String, eliminating allocations during aggregation
  • RDF code consolidation: scattered #[cfg] gates consolidated into dedicated database/rdf_ops.rs and session/rdf.rs modules

[0.5.21] - 2026-03-13

First implementation of C# and Dart bindings, single file database completed, snapshot consolidation and test hardening

Added

  • C# / .NET bindings (crates/bindings/csharp): .NET 8 P/Invoke binding wrapping grafeo-c. Covers GQL + multi-language queries (sync/async), ACID transactions, CRUD, vector search (k-NN + MMR), parameterized queries with temporal types, and SafeHandle resource management. CI on Ubuntu, Windows and macOS
  • Dart bindings (crates/bindings/dart): Dart FFI binding wrapping grafeo-c. Covers parameterized queries with temporal type encoding, ACID transactions, CRUD, vector search (MMR), NativeFinalizer for memory safety, and sealed exception hierarchy. CI on all three platforms. Based on community PR #138 by @CorvusYe
  • Single-file .grafeo database format: stores the entire database in one file with a sidecar WAL during operation (DuckDB-style). Dual-header crash safety with CRC32 checksums, auto format detection by extension, and WAL checkpoint merging. Use GrafeoDB::open("mydb.grafeo") or db.save("mydb.grafeo"). Realizes feature request #139 by @CorvusYe
  • Exclusive file locking for .grafeo files: prevents multiple processes from opening the same database file simultaneously. Lock is acquired on open and released on close/drop (uses fs2 for cross-platform advisory locking).
  • DDL schema persistence in snapshots: CREATE NODE/EDGE/GRAPH TYPE, PROCEDURE and SCHEMA definitions survive close/reopen and export/import. Snapshot format consolidated to v3 with full schema metadata
  • Crash injection testing (testing-crash-injection feature): maybe_crash() instrumentation points in write_snapshot and checkpoint_to_file enable deterministic crash simulation for verifying sidecar WAL recovery
  • Introspection functions: RETURN CURRENT_SCHEMA, RETURN CURRENT_GRAPH, RETURN info(), RETURN schema() for querying session state and database metadata from within GQL

Breaking

  • Snapshot format v3: export_snapshot()/import_snapshot() now produce/consume v3 format (includes schema metadata). Snapshots from previous versions are no longer readable. Re-export from a running database to migrate.

Testing

  • Spec compliance seam tests: systematic coverage of ISO/IEC 39075 feature boundaries and negative paths (sessions, transactions, DML, patterns, aggregates, CASE, type coercion, cross-graph isolation). Uncovered 3 spec deviations

Fixed

  • DDL in READ ONLY transactions (ISO 39075 §8): CREATE/DROP GRAPH now blocked inside READ ONLY transactions
  • SUM on empty set (ISO 39075 §20.9): returns NULL instead of 0, matching AVG/MIN/MAX
  • CASE WHEN with NULL conditions (ISO 39075 §21): NULL conditions now correctly fall through to ELSE
  • SESSION SET SCHEMA / GRAPH separation (ISO 39075 §7.1-7.2): schema and graph are now independent session fields with independent reset targets, schema-scoped graph keys, and SHOW SCHEMAS. DROP SCHEMA enforces "must be empty" per §12.3
  • COUNT(*) parsing (ISO 39075 §20.9): correctly parsed as a zero-argument aggregate

[0.5.20] - 2026-03-11

Small release bringing new methods to WASM and adding SESSION SET validation

Added

  • WASM memoryUsage() and importRows(): memory introspection and bulk row import (the DataFrame equivalent) now available in WebAssembly bindings
  • WASM vector search bindings: createVectorIndex(), dropVectorIndex(), rebuildVectorIndex(), vectorSearch() and mmrSearch() now exposed in WebAssembly, enabling client-side k-NN and MMR search with HNSW indexes

Fixed

  • SESSION SET GRAPH / SESSION SET SCHEMA validation: now errors when the target graph does not exist, matching the behavior of USE GRAPH; previously it silently accepted any name and fell back to the default store

[0.5.19] - 2026-03-11

GQL translator refactor, new methods, GQL improvements and fixes

Added

  • Graph type enforcement: full write-path schema enforcement with node type inheritance, edge endpoint validation, UNIQUE/NOT NULL/CHECK constraints, default value injection, closed graph type guards, MERGE validator support, pattern-form syntax, SHOW commands and Cypher ALTER CURRENT GRAPH TYPE
  • LOAD DATA (multi-format import): generalized LOAD DATA FROM 'path' FORMAT CSV|JSONL|PARQUET [WITH HEADERS] AS variable in GQL, with Cypher-compatible LOAD CSV syntax preserved; JSONL behind jsonl-import feature, Parquet behind parquet-import feature
  • Python import_df(): bulk-import nodes or edges from a pandas or polars DataFrame via db.import_df(df, 'nodes', label='Person') or db.import_df(df, 'edges', edge_type='KNOWS')
  • Memory introspection: db.memory_usage() returns a hierarchical breakdown of heap usage across store, indexes, MVCC chains, query caches, string pools and buffer manager regions
  • Named graph persistence: CREATE/DROP GRAPH and all mutations within named graphs are WAL-logged and recovered on restart. Snapshot v2 includes named graph data in all export/import/save paths; v1 snapshots remain backward-compatible
  • SHOW GRAPHS: SHOW GRAPHS lists all named graphs in the database, complementing existing SHOW NODE TYPES / SHOW EDGE TYPES
  • RDF persistence: SPARQL INSERT/DELETE/CLEAR/CREATE/DROP operations are now WAL-logged and recovered on restart; snapshot export/import includes RDF triples and RDF named graphs
  • Cross-graph transactions: USE GRAPH and SESSION SET GRAPH now work within active transactions; commit/rollback/savepoint operations apply atomically across all touched graphs
  • GrafeoDB graph context: one-shot db.execute() calls now persist USE GRAPH context across calls; current_graph() and set_current_graph() public API for programmatic access
  • WASM batch import: importLpg() and importRdf() methods for bulk-loading structured LPG nodes/edges and RDF triples in a single call, with index-relative edge references and typed literal support

Fixed

  • Named graph data isolation (#133): USE GRAPH / SESSION SET GRAPH now correctly route all queries to the selected graph; query cache keys include graph name; dropping the active graph resets session to default
  • OPTIONAL MATCH WHERE pushdown: right-side predicates pushed into the join instead of filtering out NULL rows
  • Cypher COUNT(expr) NULL skipping: COUNT(expr) now skips NULLs (using CountNonNull), matching COUNT(*) behavior
  • Vector validity bitmap: consecutive NULL pushes no longer silently drop null bits, fixing incorrect results in SPARQL OPTIONAL and RDF left joins

Improved

  • GQL translator submodules: split gql.rs into gql/mod.rs, expression.rs, pattern.rs, aggregate.rs for maintainability
  • Wildcard imports lint: re-enabled clippy::wildcard_imports as warning; replaced use super::* in LPG planner submodules with explicit imports
  • Unwrap reduction: replaced production .expect() calls with Result/? propagation in session initialization, persistence and WAL recovery paths

[0.5.18] - 2026-03-09

Query language compliance improvements, expanded test coverage and Deriva compatibility fixes

Added

  • Extensive spec test suites: 8 Cypher + 12 GQL spec modules covering 1,300+ test cases, plus 67 Cypher exotic integration tests (NOT EXISTS, any()/reduce, list comprehensions, OPTIONAL MATCH, CASE, multi-label, etc.)

Fixed (Cypher)

  • CALL subquery variable scope: inner RETURN columns now resolve in the outer query instead of returning NULL
  • RETURN after DELETE: delete operators pass through input rows for downstream aggregation
  • Inline MERGE with relationship SET: decomposes inline node patterns into chained MERGE operations
  • WITH * wildcard: correctly passes all bound variables through
  • DoubleDash edge patterns: undirected -- patterns now parsed alongside -[]- syntax

Fixed (GQL)

  • CALL { subquery } recognized as query-level clause instead of procedure call
  • WITH + LET bindings: LET clauses after WITH parsed and attached correctly
  • String concatenation: || (CONCAT) now supported in arithmetic expressions
  • Inline MERGE with relationship SET: same decomposition fix as Cypher

Fixed

  • Multiple NOT EXISTS subqueries: two or more NOT EXISTS predicates no longer cause variable-not-found errors
  • Transaction rollback: SET property, SET/REMOVE label, and MERGE ON MATCH SET changes all correctly undone on ROLLBACK. Savepoint partial rollback preserves earlier changes
  • NPM package missing native binaries (#128): @grafeo-db/js now publishes per-platform packages as optionalDependencies

[0.5.17] - 2026-03-09

Cypher query execution bug fixes for Deriva compatibility.

Fixed

  • Correlated EXISTS subqueries: NOT EXISTS { MATCH (a)-[r]->(b) WHERE type(r) = 'X' } now correctly plans via semi-join instead of failing with "Unsupported EXISTS subquery pattern"
  • CASE WHEN in aggregates: sum(CASE WHEN ... THEN 1 ELSE 0 END) resolves correctly inside aggregate functions
  • any()/all()/none()/single() with IN list: any(lbl IN labels(n) WHERE lbl IN ['A', 'B']) now evaluates the IN operator correctly in list predicate contexts
  • CASE WHEN in reduce(): reduce(acc = 0, x IN vals | CASE WHEN x > acc THEN x ELSE acc END) evaluates CASE expressions with both accumulator and item variable bindings

[0.5.16] - 2026-03-08

Performance enhancements, bug fixes and Rust examples

Added

  • LOAD CSV: LOAD CSV [WITH HEADERS] FROM 'path' AS row [FIELDTERMINATOR '\t'] in Cypher, with inline CSV parser supporting quoted fields, file:/// URIs and custom delimiters
  • Cypher schema DDL: CREATE/DROP INDEX, CREATE/DROP CONSTRAINT, SHOW INDEXES, SHOW CONSTRAINTS
  • Relationship WHERE: inline predicates on relationship patterns (-[r WHERE r.since > 2020]->)
  • Temporal map constructors: date({year:2024, month:3}), time({hour:14}), datetime(...), duration({years:1, months:2, days:3})
  • PROFILE statement: PROFILE MATCH ... RETURN ... executes the query and returns per-operator metrics (rows, self-time, call counts) for GQL and Cypher
  • Rust examples: 7 runnable examples in examples/rust/ covering the core API (basic queries, transactions, parameterized queries, vector search, graph algorithms, WAL persistence, multi-language dispatch)
  • Plan cache invalidation: query plan cache is automatically cleared after DDL operations (CREATE/DROP INDEX, TYPE, CONSTRAINT, etc.), with manual clear_plan_cache() API on GrafeoDB and Session
  • Cache invalidation counter: CacheStats.invalidations tracks how often DDL clears the plan cache

Improved

  • Cost model calibration: recursive plan costing, statistics-aware IO estimation, actual child cardinalities for joins, multi-edge-type expand costing
  • Supply chain audit: replaced cargo audit CI job with cargo-deny (licenses, advisories, bans, source verification)
  • Benchmark regression detection: PRs now run all three criterion suites (arena, index, query) and fail on >10% regression via benchmark-action
  • Examples CI: added cargo build -p grafeo-examples to CI checks

Fixed

  • GQL --> shorthand: parser recognizes --> as a directed outgoing edge instead of splitting into -- and >
  • EXISTS bare patterns: EXISTS { (a)-[r]->(b) } without explicit MATCH keyword now works in GQL and Cypher
  • CASE WHEN in aggregates: expressions like sum(CASE WHEN ... THEN 1 ELSE 0 END) resolve correctly in the LPG planner
  • SPARQL parameters: execute_sparql_with_params() now substitutes $param values instead of ignoring them

[0.5.15] - 2026-03-07

Full ecosystem feature profile rework and several graph database nice-to-haves

Added

  • Ecosystem feature profiles: embedded, browser, server named profiles across all crates. storage convenience group (wal + spill + mmap)
  • WASM multi-variant builds: AI variant (531 KB gzip) and lite variant (513 KB gzip) via build-wasm-all.sh, with regex-lite for smaller binaries
  • Savepoints and nested transactions: SAVEPOINT/ROLLBACK TO/RELEASE, inner START TRANSACTION auto-creates savepoints
  • Correlated subqueries: EXISTS { ... }, COUNT { ... }, VALUE { ... } in WHERE/RETURN
  • Subpath variable binding: (p = (a)-[e]->(b)){2,5} with length(p), nodes(p), edges(p)
  • Type system extensions: LIST<T> typed lists with coercion, IS TYPED RECORD/PATH/GRAPH predicates, path() constructor
  • Graph DDL: CREATE GRAPH g2 LIKE g1, AS COPY OF, CREATE GRAPH g ANY/OPEN
  • GQLSTATUS diagnostics: ISO sec 23 status codes and diagnostic records on all query results
  • Catalog procedures: CALL db.labels(), db.relationshipTypes(), db.propertyKeys() with YIELD
  • Python DataFrame bridge: result.to_pandas(), result.to_polars(), db.nodes_df(), db.edges_df() for zero-friction data science integration

Fixed

  • Temporal functions: local_time(), local_datetime(), zoned_datetime() constructors, date_trunc() truncation
  • Aggregate separators: LISTAGG and GROUP_CONCAT with custom separators and per-language defaults

Changed

  • Default profile: facade crate default changed from full to embedded. All binding crates follow
  • WASM: default changed to browser profile, binary size reduced from 1,001 KB to 513 KB gzipped (49%)

[0.5.14] - 2026-03-06

Moving crates and lots of small improvements and fixes

Added

  • EXPLAIN statement: EXPLAIN <query> in GQL and Cypher returns the optimized logical plan tree with pushdown hints ([index: prop], [range: prop], [label-first])
  • WASM size optimization: wasm-opt -Oz applied during release builds
  • NetworkX bridge: adj property and subgraph(nodes) method
  • SPARQL built-in functions: date/time (NOW, YEAR, MONTH, ...), hash (MD5, SHA1, SHA256, SHA384, SHA512), RDF term (LANG, DATATYPE, IRI, BNODE, ...) and RAND
  • GROUP_CONCAT / SAMPLE aggregates: proper implementations replacing the previous Collect stub

Fixed

  • Auto-commit for mutations: single-shot execute() calls with INSERT/DELETE/SET now auto-commit instead of silently discarding changes
  • WAL persistence for queries: mutations via GQL/Cypher now persist to WAL (previously only the CRUD API did)
  • WAL property removal: remove_node_property and remove_edge_property now log to WAL
  • Cypher count(*): parses correctly when count is tokenized as a keyword
  • SPARQL unary plus: treated as identity instead of NOT
  • CLI fixes: data dump/data load now work (JSON Lines), compact performs real compaction, index list shows per-index details, nonexistent databases error instead of being silently created
  • WASM test suite: fixed compilation and runtime panics on wasm32

Changed

  • Node.js nodeCount/edgeCount: changed from getter properties to methods (db.nodeCount())
  • Arena allocator: returns Result<T, AllocError> instead of panicking on allocation failure
  • Planner refactor: split into planner/lpg/ and planner/rdf/ with shared operator builders
  • Translator refactor: shared plan-builder functions extracted into translators/common.rs, all 7 translators moved into query/translators/
  • Dependency cleanup: removed unused deps, replaced ahash with foldhash, narrowed tokio features

[0.5.13] - 2026-03-04

Big language compliance push, schema DDL, time-travel and named graphs

Improved

  • GQL: full compliance with ISO/IEC 39075:2024, covering all features practical for a graph database
  • Cypher: improved openCypher v9 compliance, plus pattern comprehensions, CALL subqueries, FOREACH
  • SPARQL: improved W3C SPARQL 1.1 compliance (no 1.2/SPARQL Star yet)

Infrastructure

  • LPG named graphs: multi-graph support with per-graph storage, labels, indexes and MVCC versioning (create_graph(), drop_graph(), list_graphs())
  • Apply operator: correlated subquery execution for CALL, VALUE, NEXT and pattern comprehensions
  • Temporal types: Date, Time, Duration with ISO 8601 parsing, arithmetic and component extraction. Python round-trips via datetime.date/datetime.time

Schema / DDL

  • Full schema DDL: CREATE/DROP/ALTER for NODE TYPE, EDGE TYPE, GRAPH TYPE, INDEX, CONSTRAINT and SCHEMA, with OR REPLACE, IF NOT EXISTS/IF EXISTS and WAL persistence
  • Type definitions: CREATE NODE TYPE Person (name STRING NOT NULL, age INT64) with nullability
  • Index DDL: CREATE INDEX ... FOR (n:Label) ON (n.property) [USING TEXT|VECTOR|BTREE]
  • Constraint enforcement: UNIQUE, NOT NULL, NODE KEY, EXISTS validated on writes

Time-Travel

  • Epoch-based time-travel: execute_at_epoch(query, epoch) runs any query against a historical snapshot. Also available via set_viewing_epoch() or SESSION SET PARAMETER viewing_epoch = <n>
  • Version history: get_node_history(id) / get_edge_history(id) return all versions with creation/deletion epochs

GQL Spec Compliance (78% to ~97%)

  • New syntax: LIKE, CAST to temporal, SET map operations (= {map}, += {map}), NODETACH DELETE, RETURN */WITH *, list comprehensions, transaction characteristics, zoned temporals, ALTER DDL, CREATE GRAPH TYPED, stored procedures
  • List property storage: reduce() and list operations work correctly after INSERT with list-valued properties

Fixed

  • Time-travel scans: now use pure epoch-based visibility instead of transaction-aware checks
  • LIKE parser: token existed but was never consumed as an infix operator
  • RETURN * binder: was incorrectly rejected as an undefined variable
  • List comprehensions: planner now handles these in RETURN projections
  • Cypher fixes: standalone DELETE/SET/REMOVE error messages, ^ power operator, anonymous variable name collisions
  • Temporal comparison: Date/Time/Timestamp comparisons no longer silently return false

Improved

  • Test coverage: 80+ GQL parser tests (was 44), 137 Python compliance tests (was 100), new SPARQL and Cypher suites

[0.5.12] - 2026-03-02

Two-phase commit, snapshot restore, EXISTS subqueries.

Added

  • PreparedCommit: two-phase commit via session.prepare_commit(), inspect pending mutations and attach metadata before finalizing
  • Atomic snapshot restore: db.restore_snapshot(data) replaces the database in place, with full pre-validation (store unchanged on error)
  • EXISTS subqueries (GQL, Cypher): complex inner patterns with multi-hop traversals, property filters and label constraints via semi-join rewrite

Fixed

  • SET on edge variables: Cypher translator now correctly handles SET when targeting an edge variable

Improved

  • Variable-length path traversal: BFS path tracking uses shared-prefix Rc segments instead of cloning full vectors, reducing per-edge cost from O(depth) to O(1)

[0.5.11] - 2026-03-02

Pluggable storage traits, query language compliance, UNION support.

Added

  • Pluggable storage: GraphStore/GraphStoreMut traits decouple all query operators and algorithms from LpgStore. Use GrafeoDB::with_store(Arc<dyn GraphStoreMut>, Config) to plug in any backend
  • Type-safe WAL: WalEntry trait and TypedWal<R> wrapper constrain WAL record types at compile time, preventing cross-model logging
  • Query language compliance tests: spec-level integration tests for all 6 query languages
  • Cypher UNION / UNION ALL: combining query results with duplicate elimination or preservation
  • GQL MERGE on relationships: MERGE (a)-[r:TYPE]->(b) with idempotent edge creation
  • Gremlin traversal steps: and(), or(), not(), where(), filter(), choose(), optional(), union(), coalesce() and more
  • SPARQL improvements: DISTINCT, HAVING, FILTER NOT EXISTS / EXISTS

[0.5.10] - 2026-02-29

Robustness: bidirectional shortest path, crash recovery tests, stress tests.

Added

  • Skip index for adjacency chunks: compressed cold chunks maintain a zone-map skip index. contains_edge(src, dst) provides O(log n) point lookups; edges_in_range(src, min, max) supports efficient range queries
  • Bidirectional BFS shortest path: meet-in-the-middle BFS expanding smaller frontier first, reducing search space from O(b^d) to O(b^(d/2))

Improved

  • Crash recovery tests: 7 deterministic crash injection tests verifying WAL recovery at every crash point
  • Concurrent stress tests: 6 multi-threaded tests covering concurrent writers, mixed read/write, transaction conflicts, epoch pressure and rapid session lifecycle
  • Hardened panic messages: ~50 bare unwrap() calls converted to expect() with invariant descriptions; no behavioral change

[0.5.9] - 2026-02-28

Compact property storage, snapshot validation, crash injection framework.

Added

  • Snapshot validation: import_snapshot() pre-validates everything before inserting: rejects duplicate IDs and dangling edge references
  • Crash injection framework: feature-gated maybe_crash() / with_crash_at() for deterministic recovery testing, with three WAL crash points. Zero overhead when disabled
  • Backward compatibility tests: pinned v1 snapshot fixture with 8 regression tests for format stability

Fixed

  • WASM build with getrandom 0.4: added wasm_js crate feature for 0.4.x on wasm32 targets
  • WASM binary size regression: disabled transitive engine features in bindings-common, reducing WASM gzip from 974 KB to 744 KB

Improved

  • Compact property storage: property maps switched from BTreeMap to SmallVec<4>, so nodes with 4 or fewer properties avoid heap allocation
  • Cost model per-type fanout: the optimizer now tracks per-edge-type average degree instead of a single global estimate

[0.5.8] - 2026-02-22

Shared bindings crate, unified query dispatch, Node.js/WASM API expansion.

Added

  • grafeo-bindings-common crate: shared entity extraction, error classification and JSON conversion for all four bindings (Python, Node.js, C, WASM)
  • Unified query dispatch: execute_language(query, "gql"|"cypher"|"sparql"|...) replaces 18 per-language functions
  • Node.js API parity: property removal, label management, info(), schema(), version() and transaction isolation levels now match the Python binding
  • WASM expansion: parameterized queries, per-language convenience methods, proper feature gating
  • Batch edge creation: batch_create_edges() with single lock acquisition for bulk imports

Improved

  • Incremental statistics: compute_statistics() reads atomic delta counters instead of scanning all entities, reducing refresh from O(n+m) to O(|labels|+|edge_types|)
  • Cost model uses real fanout: optimizer derives average edge fanout from actual graph statistics instead of a hardcoded 10.0

[0.5.7] - 2026-02-19

UNWIND property access fix, algos feature flag.

Fixed

  • UNWIND mutation property access: map property access like e.src, e.weight in CREATE/SET now resolves correctly. Previously only column references and constants worked, so map properties came back as NULL

Added

  • algos feature flag: graph algorithms gated behind algos (included in full). Reduces compile time and binary size when algorithms are not needed

[0.5.6] - 2026-02-18

UNWIND/FOR list expansion, embedding model config, zero unsafe in property storage.

Added

  • UNWIND clause: expand lists into rows for batch processing. Works with literals, parameters (UNWIND $items AS x) and vectors. Combine with MATCH + INSERT for bulk edge creation
  • FOR statement (GQL standard): FOR x IN [1, 2, 3] RETURN x, with WITH ORDINALITY (1-based) and WITH OFFSET (0-based) index tracking
  • Text index auto-sync: text indexes update automatically on property changes, no manual rebuild needed. WASM bindings added too
  • SPARQL COPY/MOVE/ADD: graph management operators with source-existence validation and SILENT support
  • Embedding model config: 3 presets (MiniLM-L6-v2, MiniLM-L12-v2, BGE-small-en-v1.5) with HuggingFace auto-download. Exposed in Python and Node.js
  • Native SSSP procedure: CALL grafeo.sssp('node_name', 'weight') for LDBC Graphanalytics compatibility

Fixed

  • UNWIND scoping: MATCH clauses after UNWIND now correctly receive UNWIND variables, scalar values no longer resolve as node IDs and Value::Vector is handled alongside Value::List
  • RETURN n returns full entities: MATCH (n) RETURN n now returns {_id, _labels, ...properties} instead of a bare integer ID
  • GQL lexer UTF-8 panic: multi-byte characters no longer cause boundary panics
  • Scalar column tracking: Gremlin .values(), .count() and GQL WITH expr AS alias no longer return NULL
  • Vector index rebuild after drop: works without the old index, infers dimensions from data

Improved

  • Zero unsafe in property storage: replaced final transmute_copy calls with safe EntityId conversions
  • Statistics access: statistics() returns Arc<Statistics> instead of deep-cloning on every planner invocation
  • Entity resolution: moved from 6-site post-processing into the ProjectOperator pipeline for single-pass resolution

[0.5.5] - 2026-02-16

Filter pushdown, query error positions, transaction fixes.

Added

  • Filter pushdown: equality predicates on labeled scans are pushed to the store level. Compound predicates like WHERE n.name = 'Alix' AND n.age > 30 correctly split: equality pushed down, range kept as post-filter
  • Query error positions: all six parsers now produce errors with line/column positions and source-caret display

Fixed

  • Transaction edge type visibility: edges created within a transaction are now visible to subsequent queries in the same transaction
  • SPARQL INSERT/DELETE DATA with GRAPH clause: triples now route to the named graph instead of the default graph
  • Compound predicate correctness: filter pushdown no longer drops non-equality parts of compound predicates

[0.5.4] - 2026-02-15

Fixed

  • Multi-pattern CREATE: CREATE (:A {id: 'x'}), (:B {id: 'y'}) now creates all nodes instead of only the first

[0.5.3] - 2026-02-13

Improved

  • Query error quality: translator errors now produce QueryError with semantic error codes (GRAFEO-Q002) instead of generic internal errors. More actionable messages
  • GraphQL range filters: operator suffixes (_gt, _lt, etc.) now work on direct query arguments, not just where clauses

Fixed

  • SPARQL FILTER NOT EXISTS: parser now recognizes NOT EXISTS/EXISTS, producing correct anti-join/semi-join plans
  • SPARQL FILTER REGEX: REGEX evaluation was missing from the RDF planner (parser/translator already supported it)

[0.5.2] - 2026-02-13

Added

  • CALL procedure support: invoke any of the 22 built-in graph algorithms from query strings: CALL grafeo.<algorithm>() [YIELD columns]. Supported in GQL, Cypher and SQL/PGQ
  • Map literal arguments: CALL grafeo.pagerank({damping: 0.85, max_iterations: 20})
  • Procedure listing: CALL grafeo.procedures() returns all available procedures

[0.5.1] - 2026-02-12

Hybrid search, built-in embeddings, change data capture. The features that make grafeo-memory work.

Added

  • BM25 text search (text-index): inverted indexes on string properties with BM25 scoring. Built-in tokenizer with Unicode word boundaries, lowercasing and stop word removal
  • Hybrid search (hybrid-search): combine BM25 text + HNSW vector similarity via RRF or weighted fusion. Single hybrid_search() call in Python and Node.js
  • Built-in embeddings (embed, opt-in): in-process embedding generation via ONNX Runtime. Load any .onnx model, call embed_text(). Adds ~17MB, off by default
  • Change data capture (cdc): track all mutations with before/after property snapshots. Query via history(), history_since(), changes_between(). Available in Python and Node.js

[0.5.0] - 2026-02-11

Error codes, query timeouts, auto-GC, ~50% memory savings for vector workloads.

Added

  • Standardized error codes: all errors carry GRAFEO-XXXX codes (Q = query, T = transaction, S = storage, V = validation, X = internal) with error_code() and is_retryable()
  • Query timeout: Config::default().with_query_timeout(Duration::from_secs(30)) stops long-running queries cleanly
  • MVCC auto-GC: version chains garbage-collected every N commits (default 100, configurable). Also db.gc() for manual control

Improved

  • Topology-only HNSW: vectors no longer duplicated inside the index; reads on-demand via VectorAccessor trait. ~50% memory reduction for vector workloads

[0.4.4] - 2026-02-11

SQL/PGQ queries, MMR search for RAG, auto-syncing vector indexes, CLI overhaul.

Added

  • SQL/PGQ support: query with SQL:2023 syntax, SELECT ... FROM GRAPH_TABLE (MATCH ... COLUMNS ...). Includes path functions, DDL and all bindings
  • MMR search: diverse, relevant results for RAG pipelines via mmr_search() with tunable relevance/diversity balance
  • Filtered vector search: property equality filters on vector_search(), batch_vector_search() and mmr_search() using pre-computed allowlists for efficient HNSW traversal
  • Incremental vector indexing: indexes stay in sync automatically as nodes change
  • CLI overhaul: interactive shell with transactions, meta-commands (:schema, :info, :stats), persistent history, CSV output. Install via cargo install, pip install or npm install -g
  • Configurable cardinality estimation: tune 9 selectivity parameters via SelectivityConfig
  • AdminService trait: unified introspection and maintenance: info(), detailed_stats(), schema(), validate(), wal_status()
  • GQL IN operator: WHERE n.name IN ['Alix', 'Gus']
  • String escape sequences: \', \", \\, \n, \r, \t in GQL, Cypher, SQL/PGQ

Fixed

  • Node.js ID validation: rejects negative, NaN, Infinity and values above MAX_SAFE_INTEGER

Changed

  • Python CLI removed: replaced by the unified grafeo-cli Rust binary

[0.4.3] - 2026-02-08

Per-database graph model selection, snapshot export/import, expanded WASM APIs.

Added

  • Database creation options: choose LPG or RDF per database, configure durability mode, toggle schema constraints
  • Snapshot export/import: serialize to binary snapshots for backups or WASM persistence via IndexedDB
  • WASM expansion: executeWithLanguage(), exportSnapshot()/importSnapshot(), schema()

[0.4.2] - 2026-02-08

Grafeo now runs in the browser. WebAssembly bindings with TypeScript definitions at 660 KB gzipped.

Added

  • WebAssembly bindings (@grafeo-db/wasm): execute(), executeRaw(), nodeCount(), edgeCount(), full TypeScript definitions. 660 KB gzipped (target was <800 KB)
  • Feature-gated platform subsystems: parallel, spill, mmap, wal are opt-in, making wasm32 compilation straightforward

[0.4.1] - 2026-02-08

Go and C bindings. Grafeo now embeds in pretty much any language.

Added

  • Go bindings (github.com/GrafeoDB/grafeo): full CRUD, multi-language queries, ACID transactions, vector search, batch operations, admin APIs
  • C FFI layer (grafeo-c): C-compatible ABI for embedding Grafeo in any language

[0.4.0] - 2026-02-07

Node.js/TypeScript bindings, Python vector search and transaction isolation.

Added

  • Node.js/TypeScript bindings (@grafeo-db/js): full CRUD, async queries across all 5 languages, transactions, native type mapping, TypeScript definitions
  • Python vector support: pass list[float] directly, grafeo.vector(), distance functions in GQL, HNSW indexes, k-NN search
  • Python transaction isolation: "read_committed", "snapshot" or "serializable" per transaction
  • Batch vector APIs: batch_create_nodes() and batch_vector_search() for Python and Node.js

Fixed

  • GQL INSERT with list or vector() properties no longer silently drops values
  • Multi-hop MATCH queries (3+ hops) no longer return duplicate rows
  • GQL multi-hop patterns now correctly filter intermediate nodes by label
  • Transaction execute() rejects queries after commit/rollback

Improved

  • HNSW recall and speed: Vamana-style diversity pruning, pre-normalized cosine vectors, pre-allocated structures
  • Query optimizer uses actual store statistics instead of hardcoded defaults

[0.3.4] - 2026-02-06

Query timing, "did you mean?" suggestions, Python pagination.

Added

  • Query performance metrics: every result includes execution_time_ms and rows_scanned
  • "Did you mean?" suggestions: typo in a variable or label? Grafeo suggests the closest match
  • Python pagination: get_nodes_by_label() supports offset for paging

[0.3.3] - Unreleased

Added

  • VectorJoin operator: graph traversal + vector similarity in a single query
  • Vector zone maps: skips irrelevant data blocks during vector search
  • Product quantization: 8-32x memory compression with ~90% recall retention
  • Memory-mapped vector storage: disk-backed with LRU caching for large datasets
  • Python quantization API: ScalarQuantizer, ProductQuantizer, BinaryQuantizer

[0.3.2] - Unreleased

Added

  • Selective property loading: fetch only the properties you need, much faster for wide nodes
  • Parallel node scan: 3-8x speedup on large scans (10K+ nodes) across CPU cores

[0.3.1] - Unreleased

Added

  • Vector quantization: f32 to u8 (scalar) or 1-bit (binary) compression with quantized HNSW search + exact rescoring
  • SIMD acceleration: 4-8x faster distance computations; auto-selects AVX2/FMA, SSE or NEON
  • Vector batch operations: batch_insert() and batch_search() for bulk loading
  • VectorScan operators: vector similarity integrated into the query execution engine
  • Adaptive WAL flusher: self-tuning background flush based on actual disk speed
  • Fingerprinted hash index: sharded with 48-bit fingerprints for near-instant miss detection

[0.3.0] - Unreleased

Vectors are a first-class type. Graph + vector hybrid queries let you do things no pure vector database can.

Added

  • Vector type: native storage with dimension-aware schema validation
  • Distance functions: cosine, euclidean, dot product, manhattan
  • HNSW index: O(log n) approximate nearest neighbor with tunable presets (high_recall(), fast()). Also brute-force k-NN with optional predicate filtering
  • GQL vector syntax: vector([...]) literals, distance functions, CREATE VECTOR INDEX
  • SPARQL vector functions: COSINE_SIMILARITY(), EUCLIDEAN_DISTANCE(), DOT_PRODUCT(), MANHATTAN_DISTANCE()
  • Serializable snapshot isolation: ReadCommitted, SnapshotIsolation or Serializable per transaction

[0.2.7] - 2026-02-05

Parallel execution primitives, second-chance LRU cache.

Added

  • Second-chance LRU cache: lock-free access marking for concurrent workloads
  • Parallel fold-reduce: parallel_count, parallel_sum, parallel_stats, parallel_partition and a composable collector trait

[0.2.6] - 2026-02-04

Zone map filtering, clustering coefficient, faster batch reads.

Added

  • Local clustering coefficient: triangle counting with parallel execution
  • Chunk-level zone map filtering: skip entire data chunks when predicates can't match

Improved

  • Batch property retrieval acquires a single lock instead of one per entity

[0.2.5] - 2026-02-03

Full SPARQL functions, platform allocators, batch property APIs.

Added

  • Full SPARQL function coverage: string, type, math functions and REGEX
  • EXISTS/NOT EXISTS: semi-join and anti-join subqueries
  • Platform allocators: optional jemalloc (Linux/macOS) or mimalloc (Windows) for 10-20% faster allocations
  • Batch property APIs, compound predicate pushdown, range queries with zone map pruning

Improved

  • Community detection now O(E) instead of O(V^2 E), roughly 100-500x faster on large graphs

[0.2.4b] - 2026-02-02

Fixed release workflow --exclude flag (requires --workspace).

[0.2.4] - 2026-02-02

Benchmark-driven optimizations: lock-free reads, direct lookups, faster filters.

Improved

  • Lock-free concurrent reads: hash indexes use DashMap, 4-6x improvement under concurrency
  • Direct lookup APIs: O(1) point reads bypassing query planning, 10-20x faster than MATCH
  • Filter performance: 20-50x improvement for equality and range filters

[0.2.3] - Unreleased

Added

  • Succinct data structures (succinct-indexes): O(1) rank/select bitvectors, Elias-Fano, wavelet trees
  • Block-STM parallel execution (block-stm): optimistic parallel transactions, 3-4x batch speedup
  • Ring index for RDF (ring-index): compact triple storage via wavelet trees (~3x space reduction)
  • Query plan caching: repeated queries skip parsing and optimization, 5-10x speedup

[0.2.2] - Unreleased

Added

  • Bidirectional edge indexing: edges_to(), in_degree(), out_degree()
  • NUMA-aware scheduling: work-stealing prefers same-node to minimize cross-node memory access
  • Leapfrog TrieJoin: worst-case optimal joins for cyclic patterns, O(N^1.5) vs O(N^2)

[0.2.1] - Unreleased

Added

  • Tiered version index: hot/cold separation for memory-efficient MVCC
  • Compressed epoch store: zone maps for predicate pushdown on archived data
  • Epoch freeze: compress and archive old epochs to reclaim memory

[0.2.0] - 2026-02-01

Performance foundation: factorized execution to avoid Cartesian products in multi-hop queries.

Added

  • Factorized execution: avoids Cartesian product materialization, inspired by Kuzu

Changed

  • Switched from Python-based pre-commit to prek (Rust-native, faster)

[0.1.4] - 2026-01-31

Label removal, Python label APIs, all languages on by default.

Added

  • REMOVE clause: REMOVE n:Label and REMOVE n.property in GQL
  • Label APIs: add_node_label(), remove_node_label(), get_node_labels() in Python
  • RDF transactions: SPARQL now supports proper commit/rollback

Changed

  • All query languages enabled by default, no feature flags needed

[0.1.3] - 2026-01-30

CLI, Python admin APIs, adaptive execution, property compression.

Added

  • CLI (grafeo-cli): inspect, backup, export, manage WAL, compact databases
  • Admin APIs: Python bindings for info(), detailed_stats(), schema(), validate()
  • Adaptive execution: runtime re-optimization when cardinality estimates deviate 3x+ from actuals
  • Property compression: dictionary, delta, RLE codecs with hot buffer pattern

Improved

  • Query optimizer: projection pushdown, better join reordering, histogram-based cardinality estimation

[0.1.2] - 2026-01-29

Python test suite, documentation pass.

Added

  • Comprehensive Python test suite covering LPG, RDF, all 5 query languages and plugins
  • Docstring pass across all crates

[0.1.1] - Unreleased

Added

  • GQL parser: full ISO/IEC 39075 support
  • Multi-language: Cypher, Gremlin, GraphQL, SPARQL translators
  • MVCC transactions: snapshot isolation
  • Indexes: hash, B-tree, trie, adjacency
  • Storage: in-memory and write-ahead log
  • Python bindings: PyO3-based API

Changed

  • Renamed from Graphos to Grafeo, reset version to 0.1.0

[0.1.0] - Unreleased

Added

  • Core architecture: modular crate structure (common, core, adapters, engine, python)
  • Graph models: LPG and RDF triple store
  • In-memory storage: fast graph operations without persistence overhead

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.