Go

The Go binding wraps the C API through cgo. Everything is a normal Go value: properties are any, errors are ordinary errors you can compare with errors.Is, and transactions are closures so you cannot forget to finish one.

Installing

go get github.com/jeffhajewski/latticedb/bindings/go

Because it uses cgo, building needs the LatticeDB shared library available to the linker. Installing a release package puts it somewhere pkg-config can find, which is what the default build expects. If you are working inside a checkout of the repository, build the library first and use the repolocal build tag, which points the linker at zig-out/lib:

zig build
go run -tags repolocal .

Opening a database

import latticedb "github.com/jeffhajewski/latticedb/bindings/go"

db, err := latticedb.Open("graph.lattice", latticedb.OpenOptions{Create: true})
if err != nil {
    log.Fatal(err)
}
defer db.Close()

OpenOptions covers the things you have to decide when the file is created:

FieldWhat it does
CreateCreate the file if it is not there
ReadOnlyOpen without the ability to write
CacheSizeMBHow much memory to keep pages in
PageSizePage size in bytes
EnableVectorsTurn on the vector index
VectorDimensionsHow many numbers are in each vector
EnableAdjacencyCacheKeep an in-memory map of connections to speed up traversal
DisableWALOpen without write-ahead logging

Two of these need a word of explanation. DisableWAL exists because a Go bool cannot tell "the caller left this alone" apart from "the caller set it to false", and write-ahead logging defaults to on. Set DisableWAL when you genuinely want it off. There is also an older EnableVector field kept for compatibility; new code should use EnableVectors.

Writing

Update runs a function inside a write transaction. Return nil and it commits; return an error and it rolls back and hands you the error:

err = db.Update(func(tx *latticedb.Tx) error {
    alice, err := tx.CreateNode(latticedb.CreateNodeOptions{
        Labels:     []string{"Person"},
        Properties: map[string]latticedb.Value{"name": "Alice", "email": "[email protected]"},
    })
    if err != nil {
        return err
    }

    bob, err := tx.CreateNode(latticedb.CreateNodeOptions{
        Labels:     []string{"Person"},
        Properties: map[string]latticedb.Value{"name": "Bob"},
    })
    if err != nil {
        return err
    }

    _, err = tx.CreateEdge(alice.ID, bob.ID, "KNOWS", latticedb.CreateEdgeOptions{
        Properties: map[string]latticedb.Value{"since": int64(2020)},
    })
    return err
})

Value is an alias for any, so you pass Go values straight through. Strings, int64, float64, bool, []byte, and []float32 for vectors all work. Note int64 rather than int, since the stored type is explicitly 64-bit.

Only one write transaction can be open at a time. A second one fails immediately with ErrorLockTimeout rather than waiting, so if several goroutines write, they need to take turns. See One writer at a time.

Reading

View is the read-only counterpart:

err = db.View(func(tx *latticedb.Tx) error {
    name, ok, err := tx.GetProperty(nodeID, "name")
    if err != nil {
        return err
    }
    if ok {
        fmt.Println(name)   // Alice
    }
    return nil
})

Property reads return a value, whether it was there, and an error. The middle return is what separates "this property is not set" from "this property is set to something empty", which a zero value alone could not tell you.

If you would rather manage the transaction yourself, BeginRead, BeginWrite, Commit, and Rollback are available. Update and View are safer, because they cannot leave a transaction open on an early return.

Queries

result, err := db.Query(`MATCH (p:Person) WHERE p.email = "[email protected]" RETURN p.name`, nil)

The second argument is parameters, and using them is better than building query strings:

result, err := db.Query(
    "MATCH (p:Person) WHERE p.email = $email RETURN p.name",
    map[string]latticedb.Value{"email": "[email protected]"},
)

Tx.Query runs a query inside a transaction you already have open, so it sees that transaction's own uncommitted changes.

Traversal

edges, err := tx.GetOutgoingEdges(nodeID)
edges, err := tx.GetIncomingEdges(nodeID)

When you only care about one kind of relationship, filter by type and bound the result, which stops collection early instead of gathering everything and discarding most of it:

edges, err := tx.GetOutgoingEdgesByType(nodeID, "KNOWS", 100)

Property indexes

err := db.CreateNodePropertyIndex("Person", "email")

err = db.View(func(tx *latticedb.Tx) error {
    ids, err := tx.FindNodesByLabelProperty("Person", "email", "[email protected]", 10)
    // ids -> [1]
    return err
})

The limit is required and has to be greater than zero. Looking up a property with no index behind it returns an error rather than quietly scanning. See Property Indexes.

Edges use CreateEdgePropertyIndex and FindEdgesByTypeProperty.

results, err := db.VectorSearch(queryVector, latticedb.VectorSearchOptions{
    K:        10,
    EfSearch: 64,
})

K is how many neighbours you want. EfSearch trades speed for accuracy: higher values search more of the index and find more of the true nearest neighbours. Benchmarks shows the measured effect at different settings.

Store a vector on a node with tx.SetVector(nodeID, "embedding", vector), and load many at once with tx.BatchInsertVectors.

results, err := db.FTSSearch("graph database", latticedb.FTSSearchOptions{Limit: 20})

results, err = db.FTSSearchFuzzy("databse", latticedb.FTSSearchOptions{
    Limit:         20,
    MaxDistance:   2,
    MinTermLength: 4,
})

Text has to be indexed before it can be found, with tx.FTSIndex(nodeID, text).

Fuzzy search tolerates misspellings. MaxDistance is how many single-character edits away a word may be, and MinTermLength stops short words being matched loosely, where one edit can turn any three-letter word into any other.

Durable streams

err = db.Update(func(tx *latticedb.Tx) error {
    return tx.PublishStream("events", "signup", "alice joined")
})

records, err := db.ReadStream("events", 0, 10, 0)
for _, r := range records {
    fmt.Println(r.Sequence, r.Kind, r.Payload)
    // 1 signup alice joined
}

The arguments to ReadStream are the stream name, the sequence you last saw, how many records you want, and how long to wait in milliseconds when there is nothing new.

Reading does not record your position, on purpose: if it did, a crash between reading and handling a record would lose it. Save the position yourself once the work is done, inside the transaction that did the work:

err = db.Update(func(tx *latticedb.Tx) error {
    // ... handle the records ...
    return tx.SetStreamOffset("events", "billing-worker", lastSequence)
})

sequence, exists, err := db.GetStreamOffset("events", "billing-worker")

Changes reads the built-in stream of graph mutations, so you can react to writes without publishing anything yourself. TrimStream discards records every consumer has passed; nothing trims automatically.

Errors

var latticeErr *latticedb.Error
if errors.As(err, &latticeErr) {
    if latticeErr.Code == latticedb.ErrorLockTimeout {
        // somebody else is writing
    }
}

Query failures come back as QueryError, which carries where in the query text the problem is, so you can point at it rather than just reporting that something was wrong.

Where to go next