Graph Store Library
The graph store library (core/graph/) is a backend-agnostic graph database
interface for concept management. The framework ships a SQLite backend
(adjacency tables) for local and CLI use, and a server deployment can add its own
backend behind the same interface.
Architecture
Inside a kapi project the graph_nodes / graph_edges tables live in the
workspace database beside the project registry, and their node ids carry the
project they belong to (see Project store); a standalone
graph.db is the library example on this page.
Two record types travel through that interface. A node carries an ID, a
label, and a map of properties. An edge joins two nodes under a label and
may carry a validity (a ValidFrom/ValidTo interval plus a set of tags),
so a query can ask the graph a question at a point in time and within a scope. Both are defined in Key Types below.
GraphStore Interface
type GraphStore interface {
// Node CRUD
CreateNode(ctx context.Context, node *Node) error
GetNode(ctx context.Context, id string) (*Node, error)
UpdateNode(ctx context.Context, node *Node) error
DeleteNode(ctx context.Context, id string) error
// Node queries
FindNodes(ctx context.Context, label string, properties map[string]string) ([]*Node, error)
FindNodesScoped(ctx context.Context, label string, properties map[string]string, scope Scope) ([]*Node, error)
// Edge CRUD + queries
CreateEdge(ctx context.Context, edge *Edge) error
GetEdge(ctx context.Context, id string) (*Edge, error)
UpdateEdge(ctx context.Context, edge *Edge) error
DeleteEdge(ctx context.Context, id string) error
FindEdges(ctx context.Context, label string, properties map[string]string) ([]*Edge, error)
// Traversal
Neighbors(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Node, error)
NeighborsScoped(ctx context.Context, nodeID string, direction Direction, scope Scope, labels ...string) ([]*Node, error)
EdgesOf(ctx context.Context, nodeID string, direction Direction, labels ...string) ([]*Edge, error)
ShortestPath(ctx context.Context, fromID, toID string, maxDepth int) (*Path, error)
// Bulk operations
BulkCreateNodes(ctx context.Context, nodes []*Node) error
BulkCreateEdges(ctx context.Context, edges []*Edge) error
// Cypher escape hatch (AGE backend only; SQLite returns ErrCypherNotSupported)
CypherQuery(ctx context.Context, query string, params map[string]any) ([]*Node, error)
CypherExec(ctx context.Context, query string, params map[string]any) error
// Lifecycle
Close() error
}
Key Types
Node
type Node struct {
ID string `json:"id"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Edge
type Edge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Properties map[string]string `json:"properties"`
Validity *Validity `json:"validity,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Direction
type Direction int
const (
Outgoing Direction = iota // source -> target
Incoming // source <- target
Both // either direction
)
Path
type Path struct {
Nodes []Node `json:"nodes"`
Edges []Edge `json:"edges"`
}
Temporal Validity
Edges can carry temporal bounds and tag-based scoping:
type Validity struct {
ValidFrom *time.Time `json:"valid_from,omitempty"`
ValidTo *time.Time `json:"valid_to,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
}
Scope Matching
A Scope represents an evaluation point:
type Scope struct {
At time.Time `json:"at"`
Tags map[string]string `json:"tags,omitempty"`
}
Matching rules:
- Nil validity always matches (unbounded edge)
- Time: half-open interval
[ValidFrom, ValidTo) - Tags: all scope tags must be present in validity tags with matching values
- Extra validity tags not in scope are ignored (open-world assumption)
import "github.com/neokapi/neokapi/core/graph"
// Query with current time, no tag constraints
scope := graph.Now()
// Query at a specific point in time
scope := graph.ScopeAt(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC))
// Query with tag constraints
scope := graph.ScopeWithTags(map[string]string{"market": "us", "product": "enterprise"})
// Check if validity is currently active
edge.Validity.IsActive()
// Check if validity has expired
edge.Validity.IsExpired()
Edge Labels
Labels are aligned with W3C SKOS vocabulary for terminology interoperability:
// Hierarchical (SKOS)
graph.LabelBroader // "BROADER": parent concept
graph.LabelNarrower // "NARROWER": child concept
// Associative (SKOS)
graph.LabelRelated // "RELATED": associative link
// Compositional
graph.LabelPartOf // "PART_OF": component of
graph.LabelHasPart // "HAS_PART": contains component
// Terminological
graph.LabelHasTerm // "HAS_TERM": concept → term
graph.LabelUseInstead // "USE_INSTEAD": deprecated → preferred
graph.LabelReplacedBy // "REPLACED_BY": superseded → replacement
// Equivalence (SKOS)
graph.LabelExactMatch // "EXACT_MATCH": cross-scheme equivalence
graph.LabelCloseMatch // "CLOSE_MATCH": approximate equivalence
// Voice profile
graph.LabelForbidden // "FORBIDDEN": voice → forbidden term
graph.LabelPreferred // "PREFERRED": voice → preferred term
graph.LabelCompetitor // "COMPETITOR": voice → competitor term
InverseLabel() returns the inverse of directional labels (e.g., BROADER -> NARROWER).
SQLite Backend
import (
"github.com/neokapi/neokapi/core/storage"
graphstore "github.com/neokapi/neokapi/host/storage/graph"
)
db, _ := storage.Open("graph.db")
store, _ := graphstore.NewSQLiteGraphStore(db)
defer store.Close()
The backend stores adjacency tables (graph_nodes, graph_edges) with JSON
properties. Shortest path is a recursive CTE with BFS. Scoped queries filter
edges in Go after retrieval.
The SQLite backend has no native Cypher support, so CypherQuery and CypherExec return the sentinel graph.ErrCypherNotSupported. A server-side deployment can supply a backend with native Cypher support behind the same interface.
Usage Examples
Building a Concept Hierarchy
store, _ := graphstore.NewSQLiteGraphStore(db)
// Create concept nodes
store.CreateNode(ctx, &graph.Node{ID: "animal", Label: "Concept", Properties: map[string]string{"name": "Animal"}})
store.CreateNode(ctx, &graph.Node{ID: "mammal", Label: "Concept", Properties: map[string]string{"name": "Mammal"}})
store.CreateNode(ctx, &graph.Node{ID: "dog", Label: "Concept", Properties: map[string]string{"name": "Dog"}})
// Create hierarchy edges
store.CreateEdge(ctx, &graph.Edge{ID: "e1", Source: "mammal", Target: "animal", Label: graph.LabelBroader})
store.CreateEdge(ctx, &graph.Edge{ID: "e2", Source: "dog", Target: "mammal", Label: graph.LabelBroader})
// Navigate: what is broader than "dog"?
parents, _ := store.Neighbors(ctx, "dog", graph.Outgoing, graph.LabelBroader)
// parents = [mammal]
// Navigate: what is narrower than "animal"?
children, _ := store.Neighbors(ctx, "animal", graph.Incoming, graph.LabelBroader)
// children = [mammal]
// Find path from dog to animal
path, _ := store.ShortestPath(ctx, "dog", "animal", 10)
// path.Nodes = [dog, mammal, animal]
// path.Edges = [e2, e1]
Temporal Edges
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
store.CreateEdge(ctx, &graph.Edge{
ID: "e3", Source: "old-term", Target: "new-term", Label: graph.LabelReplacedBy,
Validity: &graph.Validity{
ValidFrom: &start,
ValidTo: &end,
Tags: map[string]string{"market": "us"},
},
})
// Query with scope: only returns edges active at the given time with matching tags
scope := graph.Scope{At: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), Tags: map[string]string{"market": "us"}}
neighbors, _ := store.NeighborsScoped(ctx, "old-term", graph.Outgoing, scope, graph.LabelReplacedBy)
Use FindNodes, Neighbors, and ShortestPath for portable queries that work across all backends.
Related
- C-03: The context store and graph: how the context graph uses this store.
- Project store: where the graph tables sit in a kapi project.
- Interface Reference: the framework's other Go contracts.