Skip to content

Embedded Library Guide

Every operation that can do unbounded work takes a context.Context as its first argument. Pass a real one: a query or traversal that cannot be cancelled runs to completion holding locks, long after whoever asked for it has gone. context.Background() is fine for short-lived scripts.

Crystal can be used as an embedded graph database in any Go application, similar to how SQLite is used for relational data.

Installation

go get crystal

Opening a Database

package main

import "crystal/pkg/crystal"

func main() {
    db, err := crystal.Open(crystal.Options{
        StorageType: "disk",
        DataDir:     "./data",
        SyncOnWrite: false,
    })
    if err != nil {
        panic(err)
    }
    defer db.Close()
}

Storage Types

Type Description
disk Persistent JSON file storage (default)
memory In-memory only, data lost on close

Options

Field Type Default Description
StorageType string "disk" Storage backend
DataDir string "./data" Directory for data files
SyncOnWrite bool false Full snapshot flush on every write (rarely needed)
FlushInterval time.Duration 0 Auto-checkpoint interval (0 = disabled)

CRUD Operations

Vertices

v, err := db.AddVertex(ctx, "Person", crystal.Properties{
    "name": "Alice",
    "age":  30,
})

v, err := db.GetVertex(ctx, id)

v.Properties["age"] = 31
err := db.UpdateVertex(ctx, v)

err := db.DeleteVertex(ctx, id)

Edges

e, err := db.AddEdge(ctx, sourceID, targetID, "knows", crystal.Properties{
    "since": "2020",
})

e, err := db.GetEdge(ctx, id)

e.Weight = 2.5
err := db.UpdateEdge(ctx, e)

err := db.DeleteEdge(ctx, id)

Listing

vertices, err := db.AllVertices(ctx)
edges, err := db.AllEdges(ctx)

Neighbors

neighbors, err := db.Neighbors(ctx, vertexID, crystal.DirectionOut)
neighbors, err := db.Neighbors(ctx, vertexID, crystal.DirectionIn)
neighbors, err := db.Neighbors(ctx, vertexID, crystal.DirectionBoth)

Atomic Batches

batch := db.Batch().
    PutVertex(person).
    PutVertex(server).
    PutEdge(accessed)

if err := db.Apply(ctx, batch); err != nil {
    // Nothing was applied.
}

All of the operations take effect or none do, across a crash as well as within the process. Later operations see earlier ones, so a batch can create a vertex and attach an edge to it in the same unit.

This is an atomic batch, not an interactive transaction: there is no Begin/read/decide/Commit cycle, because reading your own uncommitted writes needs snapshot isolation that Crystal does not yet have.

Consistent Reads

err := db.View(ctx, func(view crystal.Reader) error {
    edges, err := view.GetEdgesByVertex(ctx, id, crystal.DirectionOut)
    if err != nil {
        return err
    }
    for _, e := range edges {
        // Guaranteed to exist: the same view served both reads.
        v, err := view.GetVertex(ctx, e.TargetID)
        ...
    }
    return nil
})

Every read inside the callback sees the same state of the graph. Without it, two reads can straddle a write and produce a graph that never existed.

Do not write from inside a view — it holds the read lock for its duration.

Queries

The query builder provides a fluent API for filtering and paginating results.

Vertex Queries

q := db.QueryVertices().
    OfType("Person").
    WithLabel("suspect").
    PropertyEquals("city", "Berlin").
    Limit(10).
    Offset(0)

result, err := db.Execute(ctx, q)
for _, v := range result.Vertices {
    fmt.Println(v.Properties["name"])
}

Custom Filters

q := db.QueryVertices().
    OfType("Person").
    Where(func(props crystal.Properties) bool {
        age, ok := props.GetInt("age")
        return ok && age > 25
    })

result, err := db.Execute(ctx, q)

Edge Queries

q := db.QueryEdges().
    OfType("knows").
    HasProperty("since")

result, err := db.Execute(ctx, q)

Neighbor Queries

q := db.QueryFrom(vertexID).
    Out().
    OfType("Server").
    Depth(2).
    Limit(50)

result, err := db.Execute(ctx, q)

Traversal

BFS

err := db.TraverseBFS(ctx, startID, 3, func(v *crystal.Vertex, depth int) bool {
    fmt.Printf("depth %d: %s (%s)\n", depth, v.ID, v.Type)
    return true // return false to stop
})

DFS

err := db.TraverseDFS(ctx, startID, 5, func(v *crystal.Vertex, depth int) bool {
    fmt.Printf("found: %s\n", v.Properties["name"])
    return true
})

Shortest Path

path, err := db.ShortestPath(ctx, fromID, toID)
if err != nil {
    fmt.Println("no path found")
    return
}
fmt.Printf("path length: %d edges\n", len(path.Edges))
for _, v := range path.Vertices {
    fmt.Println(v.ID, v.Type)
}

Statistics

stats := db.Stats()
fmt.Printf("vertices: %d, edges: %d\n", stats.VertexCount, stats.EdgeCount)

Persistence and Durability

The disk backend uses a Write-Ahead Log (WAL). Every mutation is appended to the WAL and fsynced before being applied in memory, so acknowledged writes survive crashes.

Set FlushInterval to enable automatic checkpointing (snapshot + WAL truncation):

db, err := crystal.Open(crystal.Options{
    StorageType:   "disk",
    DataDir:       "./data",
    FlushInterval: 30 * time.Second,
})

You can also trigger a manual checkpoint at any time:

err := db.Flush(ctx)

Close() automatically stops the checkpointer and writes a final snapshot.

Investigation Mapping Example

db, _ := crystal.Open(crystal.DefaultOptions())
defer db.Close()

alice, _ := db.AddVertex(ctx, "Person", crystal.Properties{"name": "Alice"})
bob, _ := db.AddVertex(ctx, "Person", crystal.Properties{"name": "Bob"})
server, _ := db.AddVertex("Server", crystal.Properties{"ip": "10.0.0.1"})
domain, _ := db.AddVertex("Domain", crystal.Properties{"fqdn": "example.com"})

db.AddEdge(alice.ID, server.ID, "accessed", crystal.Properties{"port": 443})
db.AddEdge(bob.ID, server.ID, "accessed", crystal.Properties{"port": 22})
db.AddEdge(domain.ID, server.ID, "resolves_to", nil)

q := db.QueryFrom(server.ID).In().OfType("Person").Depth(1)
result, _ := db.Execute(ctx, q)
for _, v := range result.Vertices {
    fmt.Println("accessed by:", v.Properties["name"])
}

Dialogue Tree Example

db, _ := crystal.Open(crystal.Options{StorageType: "memory"})
defer db.Close()

start, _ := db.AddVertex("DialogueNode", crystal.Properties{
    "speaker": "NPC",
    "text":    "Hello traveler, what brings you here?",
})
opt1, _ := db.AddVertex("DialogueNode", crystal.Properties{
    "speaker": "Player",
    "text":    "I'm looking for the ancient temple.",
})
opt2, _ := db.AddVertex("DialogueNode", crystal.Properties{
    "speaker": "Player",
    "text":    "Just passing through.",
})
resp1, _ := db.AddVertex("DialogueNode", crystal.Properties{
    "speaker": "NPC",
    "text":    "Ah, the temple lies to the north.",
})

db.AddEdge(start.ID, opt1.ID, "choice", crystal.Properties{"order": 1})
db.AddEdge(start.ID, opt2.ID, "choice", crystal.Properties{"order": 2})
db.AddEdge(opt1.ID, resp1.ID, "leads_to", nil)

choices, _ := db.Neighbors(start.ID, crystal.DirectionOut)
for _, c := range choices {
    fmt.Println("->", c.Properties["text"])
}