Skip to content

fix(graph-node): execute Cypher MATCH properly and defer hydration (#879, #826) - #938

Merged
ruvnet merged 3 commits into
mainfrom
fix/graph-node-query-and-hydration
Aug 27, 2026
Merged

ruvnet merged 3 commits into
mainfrom
fix/graph-node-query-and-hydration

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Closes #879. Closes #826.

What was broken

query() handled exactly one shape — MATCH (n:Label) — through the label index. Every other shape hit an empty if branch and returned zero rows:

querySync() was worse: it never called parse_cypher at all. It returned a hardcoded empty result for any input, including the query in its own doc example.

What this does

New cypher_exec module — a single-pattern matcher (not a planner):

shape before after
MATCH (n) [] full node scan
MATCH (n:Label) worked unchanged
WHERE n.id = 'x' ignored evaluated
WHERE n.age > 30 AND ... ignored evaluated
MATCH (a)-[r:T]->(b) edges: [] edges + endpoints, direction honoured
MATCH (n {name: 'x'}) ignored evaluated
CREATE ... via query() silently dropped refused with an error
[*1..2], chained patterns silently [] error naming what was refused

query() and querySync() 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) calls hydrate_from_storage synchronously 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_blocking of whichever operation needs it first. Construction is O(1) again. deferred_hydration_replays_exactly_once covers the hazard that introduces — replaying twice and double-counting every record.

Also

  • Internal __embedding / __confidence no longer leak into result rows. A 384-element float dump was landing in every property map — invisible until query() started returning rows.
  • Scalar properties render as alice, not String("alice") (was format!("{:?}")).
  • test.js now asserts. It ran await db.query('MATCH (n) RETURN n') — precisely the broken shape — logged the result and printed ✓ Query executed regardless. 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.
  • Regenerated index.d.ts, which had drifted from the implementation: deleteNode, deleteEdge, deleteHyperedge and their result types existed in Rust but were absent from the published types.
  • Corrected stream doc examples calling db.queryStream() / db.searchHyperedgesStream() — neither method exists on GraphDatabase, and QueryResultStream::next() is itself a stub returning Ok(None).

Verification

Out of scope, filed separately

The parser binds NOT tighter than comparison, so NOT n.age = 30 arrives as (NOT n.age) = 30. That is a precedence defect in ruvector-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

, #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
ruv and others added 2 commits August 27, 2026 12:15
…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
@ruvnet
ruvnet merged commit 2f68eaa into main Aug 27, 2026
55 of 56 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant