fix(graph-node): execute Cypher MATCH properly and defer hydration (#879, #826) - #938
Merged
Merged
Conversation
, #826) `query()` handled exactly one shape — `MATCH (n:Label)` — via the label index. Everything else fell through an empty `if` branch and returned zero rows: the label-less `MATCH (n)`, every `WHERE` filter (the parser produced a `where_clause` the executor dropped), and every relationship pattern (`result_edges` was declared and never written to). `querySync()` was worse — it never parsed its argument at all and returned a hardcoded empty result for any input, including the shape in its own doc example. New `cypher_exec` module implements single-pattern matching: full node scan for the label-less case, an expression evaluator over the existing parsed `WHERE` tree, relationship patterns with direction and type filtering, and inline property maps. `query()` and `querySync()` now share one executor so they cannot drift again. Unsupported constructs (variable-length paths, chained relationships, hyperedge patterns, writes through `query()`) now return an error naming what was refused. Silently returning an empty set is what let this ship for three releases. Hydration is no longer synchronous in the NAPI constructor (#826). It ran the full store replay on the Node main thread — ~15s of blocked event loop on the 154k-node graph in #810. It now happens once, lazily, inside the `spawn_blocking` of whichever operation needs it first; construction is O(1) again. `deferred_hydration_replays_exactly_once` guards the obvious new hazard, double-counting. Also: - Internal `__embedding`/`__confidence` properties no longer leak into result rows (invisible until `query()` returned rows at all). - Scalar properties render as their value, not `String("alice")`. - `test.js` asserts instead of printing a checkmark whatever came back — it ran `MATCH (n) RETURN n`, the exact broken shape, and always passed. - Regenerated `index.d.ts`, which had drifted: it was missing `deleteNode`, `deleteEdge`, `deleteHyperedge` and their result types. - Corrected stream doc examples that called `db.queryStream()` and `db.searchHyperedgesStream()` — neither method exists. Not fixed here, filed separately: the parser binds `NOT` tighter than comparison, so `NOT n.age = 30` parses as `(NOT n.age) = 30`. Closes #879, closes #826 Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_017cMsrUW5PFR9fPahCwT5iA
This was referenced Aug 27, 2026
Closed
…elease commit The optional-deps-resolvable-on-npm guard (regression-guard.yml:351, issue #411) resolves every optionalDependency against the registry, so a package cannot declare platform packages at a version that is not published yet. Bumping to 2.1.0 here failed all five. The bump therefore belongs in its own release commit, published before it lands, so this fix can be reviewed and merged on its own. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_017cMsrUW5PFR9fPahCwT5iA
"Neo4j-compatible query language" is what #879 read before discovering that MATCH (n), WHERE and relationship patterns all returned nothing. Replaced with the shapes that actually execute, the ones that now raise an error, and the ones the lexer cannot parse at all — plus the #939 NOT-precedence caveat. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_017cMsrUW5PFR9fPahCwT5iA
13obbyMack
pushed a commit
to 13obbyMack/ruvector
that referenced
this pull request
Aug 28, 2026
Ships the ruvnet#879 / ruvnet#826 fix merged in ruvnet#938. Minor rather than patch: query() gains behaviour (label-less MATCH, WHERE, relationship patterns), and unsupported constructs now raise an error where they previously returned an empty result set. The optional-deps-resolvable-on-npm guard (issue ruvnet#411) resolves every optionalDependency against the registry, so this commit cannot go green until the five platform packages exist at 2.1.0. Publish runs from this branch first; the guard then passes and this merges. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_017cMsrUW5PFR9fPahCwT5iA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #879. Closes #826.
What was broken
query()handled exactly one shape —MATCH (n:Label)— through the label index. Every other shape hit an emptyifbranch and returned zero rows:MATCH (n)— the label-less scan, which graph-node: persisted writes invisible to stats()/query() across handles/processes; MATCH without label unimplemented #879 calls "the standard query shape for point lookups"WHEREfilter — the parser produced awhere_clause(cypher/ast.rs:38, populated atparser.rs:142) that the executor dropped on the floorresult_edgeswas declared and never written to, soedgeswas[]for all query shapes, labelled ones includedquerySync()was worse: it never calledparse_cypherat all. It returned a hardcoded empty result for any input, including the query in its own doc example.What this does
New
cypher_execmodule — a single-pattern matcher (not a planner):MATCH (n)[]MATCH (n:Label)WHERE n.id = 'x'WHERE n.age > 30 AND ...MATCH (a)-[r:T]->(b)edges: []MATCH (n {name: 'x'})CREATE ...viaquery()[*1..2], chained patterns[]query()andquerySync()now share one executor so they cannot drift apart again.The last row matters as much as the rest: returning an empty set for something you can't execute is what let this ship through three releases without anyone noticing.
Hydration (#826)
#879 asks for a release. That was blocked: the fix for #879's claims 1/2 (already on main, commit
31bb94401) callshydrate_from_storagesynchronously from the NAPI constructor, replaying the whole store on the Node main thread — ~15s of blocked event loop on the 154k-node graph from #810. Shipping #879 alone would have traded "persistence is invisible" for "constructing a handle freezes your process".Hydration now runs once, lazily, inside the
spawn_blockingof whichever operation needs it first. Construction is O(1) again.deferred_hydration_replays_exactly_oncecovers the hazard that introduces — replaying twice and double-counting every record.Also
__embedding/__confidenceno longer leak into result rows. A 384-element float dump was landing in every property map — invisible untilquery()started returning rows.alice, notString("alice")(wasformat!("{:?}")).test.jsnow asserts. It ranawait db.query('MATCH (n) RETURN n')— precisely the broken shape — logged the result and printed✓ Query executedregardless. Test 12 reopened a persisted DB and never checked the reopened handle at all. That is the mechanism by which this reached 2.0.4.index.d.ts, which had drifted from the implementation:deleteNode,deleteEdge,deleteHyperedgeand their result types existed in Rust but were absent from the published types.db.queryStream()/db.searchHyperedgesStream()— neither method exists onGraphDatabase, andQueryResultStream::next()is itself a stub returningOk(None).Verification
cargo test -p ruvector-graph-node— 21 passed (was 8; 13 new)cargo test -p ruvector-graph --features storage— 259 passednode test.js— 12 sections, now with assertionscargo fmt --checkcleang1, second same-process handle,GraphDatabase.open) reporttotalNodes: 1, and a fresh process agreesOut of scope, filed separately
The parser binds
NOTtighter than comparison, soNOT n.age = 30arrives as(NOT n.age) = 30. That is a precedence defect inruvector-graph's parser affecting every consumer, not just this binding — it deserves its own change rather than riding along here.Version bumped to 2.1.0 (minor:
query()gains behaviour, and unsupported constructs now error where they previously returned empty).🤖 Generated with claude-flow
https://claude.ai/code/session_017cMsrUW5PFR9fPahCwT5iA