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.
Problem statement
FalkorDBGraphStorestores each relation as a standaloneAGRelationnodecarrying
source/targetid 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:
as a cloud of disconnected
AGObjectandAGRelationnodes with no edgesbetween them. You can't see the graph.
(
MATCH (a)-[*1..3]->(b)), shortest-path, and graph algorithms operate onreal relationships. With relations-as-nodes, every query manually joins
AGRelation.source/.targetback toAGObject.id; the guide currently hasto tell users to match
AGRelationnodes by theirsource/targetproperties 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:
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'stype(plusid/data/provenance)as edge properties:
Because the relationship type is a fixed literal, every value stays a bound
$param— no string interpolation, no injection surface, and no need toescape or constrain relation
typestrings. 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
:AGNodeendpoint labelDangling 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::AGNode:AGObject(+type/version/data/provenance):AGNodeonlyThe shared
:AGNodelabel is required for correctness and performance, notcosmetics:
MERGE (s {id:$src})input_relationwould have no index to probe anddegrade to a full node scan on every relation write.
:AGObject) to get theindex 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:AGPlaceholderlabel. Thiskeeps a single source of truth and avoids label churn — e.g.
remove_objecton 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,
:AGPlaceholderisa cheap additive follow-on.
Operation mapping (sketch)
put_objectMERGE (o:AGNode {id:$id}) SET o:AGObject, o.type=$type, o.version=$v, o.data=$data, o.provenance=$provput_relationMERGE (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=$provall_objectsMATCH (o:AGObject) RETURN …(placeholders excluded for free)get_relation/all_relationsMATCH (s)-[r:AGRelation]->(t) RETURN r.id, s.id, t.id, r.type, r.data, r.provenance(source/targetcome from the endpoints)remove_object:AGObject+ props; delete the node if it has no remaining edges, else leave it as a:AGNodeplaceholderremove_relationrbyr.id, then delete either endpoint left as a bare:AGNodeplaceholder with no remaining edgessource/targetstop being stored properties — they fall out of the edge'sendpoints, which is the whole point.
Indexes
FalkorDB supports both node- and relationship-property indexes:
A node labeled
:AGNode:AGObjectis present in both label indexes, soendpoint merges (
:AGNode) and object reads (:AGObject) are eachindex-backed.
What this preserves
:AGNodeendpoints stand in until theobject arrives and promotes to
:AGObject. ✅core.graphprojector still deletes aremoved object's relations, which now deletes edges. ✅
id/type/data/provenanceride on the edge;source/targetcome from the endpoints. ✅$params. ✅Other notes
event log, so changing its on-disk shape migrates nothing — existing runs
re-replay (
Runtime.load(..., graph_store=…)) into the new layout. Low-riskby construction.
GraphStoreConformance(sameobservable behavior); add cases for placeholder promotion (relation before
object) and placeholder cleanup on relation removal.
AGRelation; the relationkind lives in the
typeproperty, so Browser styling/filtering keys off theproperty 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 currentnode modeling already uses.
This is a behavior-preserving internal remodeling of
FalkorDBGraphStoreonly; the
GraphStorecontract, the in-memory store, and the event log areuntouched.