Skip to content

FalkorDB relations-as-nodes break native traversal and visualization #41

Description

@dudizimber

Problem statement

FalkorDBGraphStore stores each relation as a standalone AGRelation node
carrying source/target id properties, rather than as a native graph edge.
That was a deliberate choice to preserve the in-memory store's
dangling-relation semantics (a relation may reference objects that don't
exist yet, which a native Cypher edge can't express), but it undermines the
main reason to back the projection with a graph database:

  • Visualization isn't graph-native. In FalkorDB Browser the graph renders
    as a cloud of disconnected AGObject and AGRelation nodes with no edges
    between them. You can't see the graph.
  • Native traversal/algorithms don't apply. Variable-length paths
    (MATCH (a)-[*1..3]->(b)), shortest-path, and graph algorithms operate on
    real relationships. With relations-as-nodes, every query manually joins
    AGRelation.source/.target back to AGObject.id; the guide currently has
    to tell users to match AGRelation nodes by their source/target
    properties and warns that edge-traversal algorithms don't apply directly.

The modeling round-trips correctly but defeats the value proposition (a
Cypher-queryable, shareable, graph-native current-state view). The goal is
for the FalkorDB projection to be a real graph — true edges between object
nodes — without losing dangling-relation support or cascade semantics.

Current workaround

Query relations as nodes and join back to objects by hand:

// "what does Alice know?" — no native traversal available today
MATCH (r:AGRelation {type: 'knows'})
MATCH (s:AGObject {id: r.source})
MATCH (t:AGObject {id: r.target})
WHERE s.data CONTAINS 'Alice'
RETURN t.id, t.data

Every relationship hop is a manual property-join. Multi-hop traversal,
shortest path, and any graph algorithm are effectively unavailable, and the
Browser view is unusable as a visualization. There is no client-side
workaround that yields a genuinely graph-native store — the modeling has to
change inside FalkorDBGraphStore.

Proposed solution

Model relations as native edges with a single fixed relationship type
AGRelation
, keeping the relation's type (plus id/data/provenance)
as edge properties:

(s)-[:AGRelation {id, type, data, provenance}]->(t)

Because the relationship type is a fixed literal, every value stays a bound
$param
— no string interpolation, no injection surface, and no need to
escape or constrain relation type strings. The store becomes graph-native
(real edges, native traversal, a connected Browser view); traversal by kind is
a property filter: MATCH (s)-[r:AGRelation {type:'knows'}]->(t).

Node model: a shared :AGNode endpoint label

Dangling relations need an endpoint node to exist before its object does, so
decouple "is this object known?" from "does this node exist?". Every endpoint
carries a shared structural label :AGNode; real objects additionally carry
:AGObject:

  • Object → :AGNode:AGObject (+ type/version/data/provenance)
  • Placeholder (dangling endpoint) → :AGNode only

The shared :AGNode label is required for correctness and performance, not
cosmetics:

  • A bare, label-less node can't be indexed (FalkorDB indexes are per-label), so
    MERGE (s {id:$src}) in put_relation would have no index to probe and
    degrade to a full node scan on every relation write.
  • MERGEing on a type-specific label instead (e.g. :AGObject) to get the
    index risks duplicate-identity nodes when the same id later promotes or
    already exists under different circumstances. The MERGE label has to be one
    that every endpoint shares — hence :AGNode.

Placeholder state is derived, not labeled. A dangling endpoint is exactly
:AGNode AND NOT :AGObject; there is no separate :AGPlaceholder label. This
keeps a single source of truth and avoids label churn — e.g. remove_object
on a still-referenced object naturally demotes it back to a placeholder (the
node survives because it still has edges) with no extra label bookkeeping. If
direct introspection of dangling endpoints is ever wanted, :AGPlaceholder is
a cheap additive follow-on.

// find dangling endpoints, no extra label needed
MATCH (n:AGNode) WHERE NOT n:AGObject RETURN n.id

Operation mapping (sketch)

Op Cypher
put_object MERGE (o:AGNode {id:$id}) SET o:AGObject, o.type=$type, o.version=$v, o.data=$data, o.provenance=$prov
put_relation MERGE (s:AGNode {id:$src}) MERGE (t:AGNode {id:$tgt}) MERGE (s)-[r:AGRelation {id:$id}]->(t) SET r.type=$type, r.data=$data, r.provenance=$prov
all_objects MATCH (o:AGObject) RETURN … (placeholders excluded for free)
get_relation / all_relations MATCH (s)-[r:AGRelation]->(t) RETURN r.id, s.id, t.id, r.type, r.data, r.provenance (source/target come from the endpoints)
remove_object drop :AGObject + props; delete the node if it has no remaining edges, else leave it as a :AGNode placeholder
remove_relation delete r by r.id, then delete either endpoint left as a bare :AGNode placeholder with no remaining edges

source/target stop being stored properties — they fall out of the edge's
endpoints, which is the whole point.

Indexes

FalkorDB supports both node- and relationship-property indexes:

CREATE INDEX FOR (n:AGNode)        ON (n.id)     -- backs endpoint MERGE
CREATE INDEX FOR (n:AGObject)      ON (n.id)     -- backs object reads
CREATE INDEX FOR ()-[r:AGRelation]->() ON (r.id)    -- get/remove relation by id
CREATE INDEX FOR ()-[r:AGRelation]->() ON (r.type)  -- traversal by kind
CREATE INDEX FOR (n:AGPatch)       ON (n.id)

A node labeled :AGNode:AGObject is present in both label indexes, so
endpoint merges (:AGNode) and object reads (:AGObject) are each
index-backed.

What this preserves

  • Dangling relations — placeholder :AGNode endpoints stand in until the
    object arrives and promotes to :AGObject. ✅
  • Cascade-on-removal — unchanged; the core.graph projector still deletes a
    removed object's relations, which now deletes edges. ✅
  • Round-trip fidelity — id/type/data/provenance ride on the edge;
    source/target come from the endpoints. ✅
  • Security — fixed relationship type means all values remain bound
    $params. ✅

Other notes

  • No data migration. The FalkorDB store is a disposable projection of the
    event log, so changing its on-disk shape migrates nothing — existing runs
    re-replay (Runtime.load(..., graph_store=…)) into the new layout. Low-risk
    by construction.
  • Conformance. The change is invisible to GraphStoreConformance (same
    observable behavior); add cases for placeholder promotion (relation before
    object) and placeholder cleanup on relation removal.
  • Tradeoff (cosmetic). All edges share the label AGRelation; the relation
    kind lives in the type property, so Browser styling/filtering keys off the
    property rather than the edge label. This is the price of keeping the write
    path injection-free, and it matches the type-as-attribute shape the current
    node modeling already uses.

This is a behavior-preserving internal remodeling of FalkorDBGraphStore
only; the GraphStore contract, the in-memory store, and the event log are
untouched.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions