Facts from stores
A source that answers from its own index: lookups, several stores federated as one, and stores that join, count, page and walk, so a question asks for what it needs rather than loading everything.
A Source that serves only Tuples hands over a relation whole: the first query touching pin loads every pin fact into the base. That’s fine for facts already in memory. A host whose facts live in a database, an on-disk index or a server can implement more of ns’s optional interfaces, and then a query asks the store for what it binds, has it join, count, page or walk, and spreads one question over several stores. Each interface is opt-in and the answers never depend on it: a store may always return more than was asked, never less, and jaala checks what comes back.
The examples below put the guide’s imports facts behind small stores that print each call, so the output shows what went to the store. A real one would make one round trip per printed line.
package guide_test
import (
"context"
"fmt"
"iter"
"slices"
"strings"
"github.com/panyam/jaala/datalog"
"github.com/panyam/jaala/ns"
)
// looker is source()'s facts behind a store that answers lookups, printing
// each call, as a store over a database makes a round trip per call. The
// engine checks every fact it gets back, so a store may return more than
// matches, but never fewer.
type looker struct {
*ns.MemSource
name string
}
func (s looker) Lookup(_ context.Context, rel string, bound map[int]ns.Value) ([]ns.Tuple, error) {
fmt.Printf("%s: lookup %s %s\n", s.name, rel, s.text(rel, bound))
return s.matching(rel, bound), nil
}
func (s looker) matching(rel string, bound map[int]ns.Value) []ns.Tuple {
var out []ns.Tuple
for _, t := range s.Tuples(rel) {
ok := true
for i, v := range bound {
ok = ok && t.Vals[i].S == v.S
}
if ok {
out = append(out, t)
}
}
return out
}
// text writes bound values by their labels, from=api.
func (s looker) text(rel string, bound map[int]ns.Value) string {
sch, _ := s.Schema(rel)
var parts []string
for i, l := range sch.Labels {
if v, ok := bound[i]; ok {
parts = append(parts, l+"="+v.S)
}
}
return strings.Join(parts, " ")
}
func storeBase(src ns.Source) *datalog.Base {
v, err := vocabulary(src)
if err != nil {
panic(err)
}
return datalog.MustBase(v, src)
}
func answer(b *datalog.Base, query, col string) {
rows, err := datalog.SemiNaive{}.Eval(context.Background(), datalog.MustParse(query), b)
if err != nil {
fmt.Println(err)
}
var vals []string
for _, r := range rows {
vals = append(vals, r.Bind[datalog.Var(col)].S)
}
fmt.Println(strings.Join(vals, " "))
}
// A store that looks facts up is asked only for what the query binds. The
// demand rewrite binds each package depends_on reaches, so the store is
// asked about app, then api, then log, then util, and never read whole.
func Example_storeLookup() {
answer(storeBase(looker{source(), "store"}), `deps.depends_on("app", ?d) => ?d`, "d")
// Output:
// store: lookup imports from=app
// store: lookup imports from=api
// store: lookup imports from=log
// store: lookup imports from=util
// api log util
}
// A federation is one source over several stores. Each member's Holds says
// which calls it can match, so a lookup goes only to the members that might
// answer: app's and api's imports live in one repo, log's in another, and
// nobody is asked about util, which no member holds.
func Example_storeFederation() {
all := source()
split := func(froms ...string) *ns.MemSource {
m := ns.NewMemSource().Declare("imports", "from", "to").Declare("pkg", "name", "license")
for _, t := range all.Tuples("imports") {
if slices.Contains(froms, t.Vals[0].S) {
m.Add("imports", t)
}
}
return m
}
holds := func(froms ...string) func(string, map[int]ns.Value) bool {
return func(rel string, bound map[int]ns.Value) bool {
v, ok := bound[0]
return rel != "imports" || !ok || slices.Contains(froms, v.S)
}
}
f, err := ns.NewFederation(
ns.Member{Name: "web", Source: looker{split("app", "api"), "web"}, Holds: holds("app", "api")},
ns.Member{Name: "lib", Source: looker{split("log"), "lib"}, Holds: holds("log")},
)
if err != nil {
panic(err)
}
answer(storeBase(f), `deps.depends_on("app", ?d) => ?d`, "d")
// Output:
// web: lookup imports from=app
// web: lookup imports from=api
// lib: lookup imports from=log
// api log util
}
// joiner is a looker that also evaluates a join itself (ns.PushSource), as
// SQLite would run a conjunction as one statement. This one takes a join of
// plain atoms and nothing else: Pushes is asked while planning, and a
// conjunction it refuses is asked literal by literal instead.
type joiner struct{ looker }
func (s joiner) Pushes(c ns.Conjunction) bool {
return len(c.Compare) == 0 && len(c.Filter) == 0 && !c.Partial && c.Page == nil && len(c.Inputs) == 0
}
func (s joiner) Push(_ context.Context, c ns.Conjunction) iter.Seq2[[]ns.Tuple, error] {
var atoms []string
for _, a := range c.Atoms {
var args []string
for _, arg := range a.Args {
args = append(args, fmt.Sprintf("?%d", arg.Var))
}
atoms = append(atoms, a.Relation+"("+strings.Join(args, ", ")+")")
}
fmt.Printf("%s: push %s\n", s.name, strings.Join(atoms, ", "))
return func(yield func([]ns.Tuple, error) bool) {
s.join(c, 0, map[int]string{}, nil, yield)
}
}
// join finds every combination of facts satisfying c's atoms from atom i on,
// given the variables bound so far.
func (s joiner) join(c ns.Conjunction, i int, vars map[int]string, row []ns.Tuple, yield func([]ns.Tuple, error) bool) bool {
if i == len(c.Atoms) {
return yield(slices.Clone(row), nil)
}
for _, t := range s.Tuples(c.Atoms[i].Relation) {
next, ok := map[int]string{}, true
for k, v := range vars {
next[k] = v
}
for j, arg := range c.Atoms[i].Args {
switch {
case arg.Const != nil:
ok = ok && t.Vals[j].S == arg.Const.S
case arg.Var != 0:
if v, seen := next[arg.Var]; seen {
ok = ok && v == t.Vals[j].S
}
next[arg.Var] = t.Vals[j].S
}
}
if ok && !s.join(c, i+1, next, append(row, t), yield) {
return false
}
}
return true
}
// A join the store can evaluate goes to it as one call, which ships only the
// rows that join, where lookups would cost a call per literal.
func Example_storePush() {
answer(storeBase(joiner{looker{source(), "store"}}), `imports(?a, ?b), imports(?b, ?c) => ?c`, "c")
// Output:
// store: push imports(?1, ?2), imports(?2, ?3)
// log util
}
// walker is a looker that also follows a relation from value to value
// itself (ns.WalkSource), as SQLite does with WITH RECURSIVE. It returns
// every fact it reaches, since the engine asks nothing more about a value it
// reached.
type walker struct{ looker }
func (s walker) Walks(rel string, from, to int, bound []int) bool { return rel == "imports" }
func (s walker) Walk(_ context.Context, w ns.Walk) iter.Seq2[ns.Tuple, error] {
var start []string
for _, v := range w.Start {
start = append(start, v.S)
}
fmt.Printf("%s: walk %s from %s\n", s.name, w.Relation, strings.Join(start, " "))
return func(yield func(ns.Tuple, error) bool) {
seen, todo := map[string]bool{}, slices.Clone(start)
for len(todo) > 0 {
at := todo[0]
todo = todo[1:]
if seen[at] {
continue
}
seen[at] = true
for _, t := range s.matching(w.Relation, map[int]ns.Value{w.From: ns.S(at)}) {
if !yield(t, nil) {
return
}
todo = append(todo, t.Vals[w.To].S)
}
}
}
}
// A recursive rule following a relation probes it round by round, a call
// per level. A store that walks is asked once for the whole walk instead.
// deps.depends_on reads imports through _step, a helper that only renames
// it, so _step is inlined into the recursive rule and the walk is found too.
func Example_storeWalk() {
reach := `reach(?a, ?b) :- imports(?a, ?b); reach(?a, ?c) :- reach(?a, ?b), imports(?b, ?c); `
answer(storeBase(walker{looker{source(), "store"}}), reach+`reach("app", ?d) => ?d`, "d")
answer(storeBase(walker{looker{source(), "store"}}), `deps.depends_on("app", ?d) => ?d`, "d")
// Output:
// store: walk imports from app
// api log util
// store: walk imports from app
// api log util
}
A source with its own index ¶
A source reads a relation whole: the first query touching pin loads every pin fact into the base. A source backed by a database or an on-disk index can do better by implementing ns.LookupSource (#126). Then a call with an argument bound, such as pin(?r, "GND"), or one that demand bound for a derived relation’s body, asks the source’s Lookup for just the facts it needs. Lookup gets the bound positions and their values. It may return facts that don’t match, since the engine checks each one, but it must not leave out one that does. A bound value arrives already read as its argument’s declared type, and a stored value matches when, read as that type too, it’s the same value: two numbers by value however they’re spelled, two texts by their text, and a number never text. jaala reads what Lookup returns that way, so a pin stored as the number 3 matches a bound "3" in a pin column. A source whose stored kinds already match its declared types can compare as stored. Example_storeLookup above asks about each package depends_on reaches, one call each, and never reads imports whole.
What a lookup returns is kept for the rest of the Eval that asked, and by the Base for later queries, up to a million tuples across all lookups by default (#212). A host that keeps a base open and asks the same kinds of question again goes back to the store only for what it hasn’t asked before. When the cache is full, the results used least recently go first, so a relation the queries only probe is held only as far as it was probed and never past the limit. LimitLookupCache(n) sets the limit for a base and every copy of it, and LimitLookupCache(0) keeps nothing, so every query asks the source again. A Versioned source whose version moves drops what was kept, as Forget does, and Explain counts a call answered this way as kept, apart from the query’s own hits. A call with nothing bound still reads the relation whole, and the base then keeps it and answers later bound calls from its own index. Each lookup counts one unit of work against the Budget, and a failed one stops the query with an error naming the relation and isn’t kept. A store whose one call does much more than it returns, scanning a table or calling another service, can charge that work to the query’s budget itself through ns.AllowanceFrom(ctx), on the context of any read, lookup, push or walk (#361): Spend(n) errs once the budget is spent, and the store returns that error as it is, so the query stops with its budget’s own error. What a store spends is reported apart from the query’s work, in Report.Spent. Unindexed never looks anything up, so it stays the oracle a host compares against. Since the planner can’t size a relation it hasn’t read, it orders those calls by how many arguments they bind.
Facts in several stores ¶
A host whose facts live in several stores, say one per repo or a project’s own facts beside a shared parts library, doesn’t have to merge them into one source first. ns.NewFederation builds one source over several members (#211). Each relation’s facts are the union of what its members serve, and a relation two members serve must have the same arity, labels and types in both, or NewFederation refuses it. In Example_storeFederation, app’s and api’s imports are in one store and log’s in another, so each lookup goes to one store, and util, which neither holds, costs no call.
A member’s Holds says which calls it can match. ns.HoldsPrefix("github.com/acme/api/", map[string][]int{"node": {0}}) says every node id the member stores starts with that prefix, so a lookup of node("github.com/acme/web/main.go", ?k) skips it. Binding fewer arguments must never answer false where binding more would answer true, since a federation may ask about a relation with nothing bound before it tries each value (#307). A lookup goes only to the members that can hold a match, which makes a query asking about one repo cost one store’s round trip, not fifty. A relation is looked up only when every member serving it answers lookups; otherwise it’s read whole from every member, once, and kept like any other relation read whole. A member’s Name, when set, prefixes its facts’ citations (api:main.go:12), so an explanation says which store each fact came from. The federation’s version moves whenever a member’s does. A fact two members both serve is two facts, as it would be in one source serving it twice: answer rows are distinct either way, but an aggregate counting bindings counts it twice.
A source can also say which lookups it answers from an index by implementing ns.IndexedLookups (#154): Indexed(rel, bound) reports whether a lookup of rel with those positions bound reads an index. The planner can’t size a relation it hasn’t read, so it orders looked-up calls by how many arguments they bind, and a call the source would scan ranks as if it bound nothing. In attr(?m, "lines", "7"), edge("repo0/n3", _, ?m), with attr indexed only by its id, the planner reads edge by its source first and then looks up attr by each ?m, where it would otherwise scan every attr for the key and value. A source that doesn’t implement it is taken as indexing everything. A federation indexes a pattern only when every member serving the relation does, since one member’s scan makes the call a scan.
Under demand, a body probes a looked-up relation once for each value it’s given, so a question reaching 500 nets makes 500 calls, each a round trip when the store sits behind a worker or a network. A source that implements ns.BatchLookupSource (#153) is asked for all of them at once instead: LookupBatch(ctx, rel, positions, values) gets the bound positions and a list of values, and returns one result per value, in that order, each under Lookup’s rules. The planned SemiNaive finds the values by running the body up to the call, asks for the ones it hasn’t looked up in batches of up to 1024, and then solves the body from what came back, so reach("repo0/n0", ?x) costs one call per level of the walk rather than one per node. A negated call, not attr(?x, "v0"), gets its values from one more run of the whole positive body (#230). One call per level of a walk is as far as batching goes, since each level’s values come from the level before; a store that walks the graph itself is pushdown (#213). The extra run of the body works in memory. A federation batches whatever its members are, asking each member once for the values its Holds allows, and a member that only looks up is asked one value at a time. Explain counts batches apart from lookups (Report.Batches).
Facts with ids ¶
A query can name the fact an atom matched, imports(?a, ?b) as ?r, to return it, compare two or read what the store keeps about it (#279, the syntax page). The name is the tuple’s ns.Tuple.ID (ns.TimedTuple.ID for a timed source), which a store sets to whatever it already calls the fact: an edge’s row id, a relationship’s key. Two facts with one id read as one fact to a query comparing them, so an id has to be unique across a federation’s members too. A tuple without one is named by its relation and values, imports("app", "api"), so two facts with the same values share a name, which is fine for a store that holds each fact once.
Nothing else changes for the store. A call binding its fact is asked of the store as the same call without it: imports("app", ?b) as ?r is a lookup of imports("app", ?b), batched like any other, with ?r taken from each tuple that comes back. A call with only the id bound reads the relation whole, since no store interface looks a fact up by its id yet. A store keeping attributes of a fact serves them as an ordinary relation keyed by the id, imports_attr(id, key, value), which a query joins on ?r.
A store that joins ¶
A store that can evaluate a join, such as SQLite turning a conjunction into one statement, can implement ns.PushSource (#237). The planned SemiNaive then hands it a group of a body’s base literals joined by shared variables, with the comparisons and filter predicates over them, and solves the rest itself. Example_storePush hands its store the two-literal join as one call. edge("repo0/n5", _, ?m), edge(?m, _, ?o) becomes one call that ships the rows that join, where lookups would cost a call per literal and ship every intermediate tuple. A single literal goes too when the store can filter it and nothing else would (#263): edge(?a, ?k, ?a) repeats a variable, and weight(?n, ?w), ?w > 7 has a comparison the store takes, so each ships its matches instead of the whole relation. A literal with a constant is still a lookup, and one of distinct free variables is read whole. Pushes(c) says whether the store takes a conjunction. The planner asks it while planning, growing a group one literal at a time and offering it with its comparisons and filters, then without the filters, then without either, so it has to be cheap and do no I/O. A filter predicate such as str.glob(?c, "net/http.*") goes as Conjunction.Filter when the store names it in ns.FilterSource (#297), which it does only when it applies the predicate exactly as the vocabulary does; str.glob has SQLite GLOB’s semantics, so a SQLite store can apply it as GLOB. A store that names none is sent none, since one that accepted a conjunction and ignored a filter it didn’t know would join every row the filter drops. A group stops growing at a literal outside it that only filters what it bound, such as a derived relation over one of its variables, or a filter predicate the store refused, so the store isn’t asked to join rows the filter would drop (#291). Push(ctx, c) streams one row per combination of facts that satisfies the conjunction, each row one tuple per atom in the order asked.
A row follows Lookup’s rules: the engine reads each tuple as its argument’s declared type and checks it against the literals, the comparisons and the filters again, so a row that doesn’t satisfy them is dropped, but one that does must not be left out. A store that can’t compare as jaala does (absent first, then numbers by value, then text, with a number in no unit comparing with any unit) skips that comparison and leaves it to the engine, and likewise any filter. A group may also read variables something earlier in the body binds, such as a demanded rule’s guard or a recursive round’s delta (#246): the planner offers it with those variables as Conjunction.Inputs, and a store that takes them is later asked once for a batch of bindings (Conjunction.Bindings), returning every match for each, as LookupBatch does for one literal. A store that declines is asked literal by literal, with its lookups batched as before, never a push per binding. A partitioned federation of several members takes inputs when every member does (#268): each binding goes to the members whose Holds allows the group’s first literal under its values, each of them asked once for its own bindings, and an unpartitioned one declines. Where a match leaves one member, the rest of the group goes to the others once for every such prefix, with the prefixes’ values as its bindings, rather than once per prefix. A federation of any kind pushes a single literal to each member serving it. Each row is charged to the Budget as it arrives. A completed push is kept like a lookup’s result, and one that failed or stopped isn’t. A federation of one member pushes what that member pushes. Explain shows a pushed group as one literal, pushed{edge("repo0/n5", _, ?m), edge(?m, _, ?o)}, and counts the calls in Report.Pushes.
Across several stores a join can pair facts from different stores, which no one store sees. A federation built with ns.NewPartitionedFederation pushes anyway (#238). It sends the conjunction to each member whose Holds allows its first literal, asking for every prefix of its matches too (Conjunction.Partial), so a member answers the matches it holds whole and also says where each one stopped. A prefix whose next fact another member’s Holds allows is continued there, with the prefix’s values as constants. With stores partitioned by repo, edge("repo0/n20", _, ?m), edge(?m, _, ?o) costs one call to repo0, and one to repo1 for the edge that leads there, where asking literal by literal costs three. The answer doesn’t depend on how precisely Holds routes, since a prefix is continued in every other member Holds allows, so a loose Holds costs calls rather than matches. The federation pushes only when every member serves every relation of the conjunction and pushes it with its prefixes; otherwise it’s asked literal by literal. A store asked for prefixes must return them, or the matches spanning stores are lost, so a store that can’t refuses a Partial conjunction in Pushes. A member is asked for prefixes only when another member’s Holds allows a literal after the first; otherwise it gets the conjunction whole, without Partial. A conjunction with a literal no member’s Holds allows is answered with nothing, and no store is asked (#307). One that pushes a conjunction also pushes its suffixes with some variables given as constants.
A store that counts and pages ¶
A goal whose whole body a store could evaluate goes to it whole (#240, #241): base relations it serves, and comparisons over their variables, with no negation and no derived relation. Over a federation that means a goal of one literal, whose matches are each one store’s facts, or any goal over one store, or a join over a partitioned federation (#253). There each store summarizes or pages the matches it holds whole, and also sends only the prefixes of its matches (Conjunction.PrefixesOnly), which the federation continues in the stores holding the next fact, as a partitioned push does. Each match spanning stores comes back as a summary row of one match, or as a candidate for the page, so every match is counted once. A join’s summary across stores asks for values, not extremes, since the federation can’t read a crossing match’s numbers as their types.
A goal that aggregates, attr(?id, "lines", "7") => count(?id), asks a store implementing ns.SummarySource for a summary: Summarize(ctx, c, ns.Summary{Group, Values}) returns the matches grouped by the values of the group’s variables and of the variables the aggregates reduce, each row with how many matches it covers and their citations. A plain count reduces no values, so it is one row per store. jaala reduces the rows with its own aggregator, so sums stay exact, numbers in mixed units answer absent, list repeats a value as often as it occurred, and Row.Why says why. Two matches share a row only when their values are identical in text, number and unit, since 5 V and 5 A are one number in two units; a store may split rows it could have merged, but must count every match once. A store may skip the goal’s comparisons, since jaala asks for the values they read and checks each row.
A variable only min and max read goes as one of the summary’s Extremes (#252): rather than group by its values, a row reports, per unit, how many of its matches hold a number there, whether one was NaN, and the smallest and largest of the rest (ns.Extreme). A max over a column of distinct measurements is then a row per group rather than one per value. jaala still decides the answer: numbers in different units answer absent, and a NaN makes it NaN. A store that can’t report extremes refuses a summary with them in Summarizes, and is asked for the values instead. sum always goes as values, since an exact sum isn’t something a store’s SUM gives.
A goal with a limit sets Conjunction.Page: the answer’s columns, the order by keys and limit plus offset. The store returns every match behind its first that many distinct rows in jaala’s order (absent, then numbers by value, then text), and jaala answers from them as from every match, so the rows, the binding each row cites and its witness are the same. Each store’s first rows hold the federation’s, so a page of 10 over 50 stores ships at most 10 rows from each. A page after a cursor, from the After option or the query’s after clause, carries the cursor’s values in Page.After (#254), and the store takes its first rows after them in the same order, so every page of a listing ships its limit per store. jaala applies the cursor again, so a store that sends rows from before it costs shipping, not answers. A goal with a comparison, or a Total asked for, isn’t paged.
A store that walks ¶
A recursive rule that follows a relation, as walk(?a, ?c) :- walk(?a, ?b), next(?b, ?c) follows next, probes it in rounds: each round looks up the values the last round found, so a walk 60 steps deep costs 60 calls however the lookups batch. A store that can follow a relation itself, as SQLite does with WITH RECURSIVE, can implement ns.WalkSource (#239). The planned SemiNaive then asks it once, Walk(ctx, ns.Walk{Relation: "next", From: 0, To: 1, Start: [n0]}), and the store returns every next fact it reaches from n0 by following each fact’s dst as the next src. jaala files each reached value’s facts as the lookup that value would have made, a value with no facts as an empty answer, and runs its fixpoint as before, so answers, citations and witnesses don’t change, only the calls. Example_storeWalk makes one call for the whole reach. The recursive rule has to read the relation, or a helper that only renames it: deps.depends_on reads imports through _step, whose one rule is _step(?a, ?b) :- imports(?a, ?b), so _step is inlined into the recursive rule and the walk is found (#319). A helper that joins, compares or calls a predicate isn’t inlined there, since the fixpoint would redo it every round, so a recursion through one is probed round by round.
A walk follows Lookup’s rules. The store may return facts that aren’t reached, but for a value it reaches it must return every fact, since jaala asks nothing more about that value. jaala files only the values it reaches itself from the start values, so a stray fact never stands in for a value’s whole answer. A walk may fix other arguments to constants (edge(?b, "calls", ?c) walks only calls), and is charged to the Budget fact by fact. A completed walk is kept as lookups are; one that failed or stopped isn’t, and its error reads as a failed lookup. A probe a walk didn’t cover, such as a value the recursion reached through another relation, is looked up as before, or walked again from there.
A federation walks across its members whatever Holds says: each start value goes to the members Holds allows, each member walks its values in one call, and a value it reached goes on to the other members Holds allows, each member walking a value once. A walk that stays in one repo costs one call, and one crossing into another repo a call per store. Explain counts walks in Report.Walks and shows the literal as walked (src).
graph.closure and graph.paths walk their step relation in the engine, a node at a time, so over a store each would cost a lookup for every node it reaches (#396). The planned SemiNaive walks ahead of such a call instead, from every start the body binds it to. A WalkSource that walks the step relation is asked once, with ns.Walk.MaxDepth set to the call’s max (0 when it has none), and a store that only batches gets one LookupBatch per level of the walk. A store may stop at MaxDepth: jaala takes a value reached at the bound as one it didn’t walk, so a later question going further looks it up rather than finding it empty. A store built before MaxDepth existed walks to the end, which costs fetches but gives the same answers. A federation passes each crossing value on with the steps it has left, and walks a value again where it arrives with more steps left than it was walked with. With both ends bound, the batched walk stops once it reaches the bound end. A closure with neither end bound reads its step relation whole, so the planner runs it after any literal it could start from.
A recursive rule whose step is several literals, r(?a, ?c) :- r(?a, ?b), edge(?b, ?m), call(?m, ?c), walks a conjunction rather than a relation (#250). A store implementing ns.ConjunctionWalkSource is asked once, WalkConjunction(ctx, c, in, out, start), for every match of the step from the start values, following each match’s out value as the next in, and jaala files each reached value’s matches as the batched push (#246) of that value would have. It walks only a step the planner keeps as one pushed group: when the demand rewrite stores part of the step in a prefix relation (a literal bound by constants, such as a kind filter, is often split off that way), the rounds go to batched pushes instead. A federation walks a step only through a lone member.
A graph server as the store ¶
A host whose graph already lives in Neo4j or Memgraph doesn’t have to write any of the above: stores/bolt is a source over any server that speaks Bolt, Neo4j’s client protocol (#411). It’s a Go module of its own, so the Neo4j driver it uses reaches only the hosts that import it. bolt.New(ctx, driver, graph, schemas, opts) takes a driver the host opened (its address, auth and TLS are the host’s), the relations’ schemas, and a cypher.Graph, the same one the Cypher front end compiles over, so a host describes its graph once. A label’s relation is the nodes holding that label (node(id, "Function") is a node labelled Function), a type’s relation the relationships of that type, and the graph’s Props relation (attr(id, key, value)) the nodes’ other properties. Every node also carries the label JaalaNode, and New makes sure the server has an index on its id.
The store reads a relation as a stream, looks a call up by its bound key, endpoint, property name or text property, and batches those lookups into one query per label or type, with the values unwound on the server. It says a lookup binding a key or an endpoint is indexed, so the planner starts there. A bound number isn’t sent, since jaala reads a number as its argument’s value and unit (folded, when the vocabulary folds), so the server returns more and jaala keeps what matches. A join, a count and a page go to the server as Cypher too (#412): Push writes a conjunction as one query with the conditions the server matches exactly as jaala does (node ids, text, absent values), and leaves a number’s constant or join, and an ordering comparison, to jaala. Summarize counts per group on the server, and a page orders by a hex key the store writes beside each node’s id, since Neo4j can order a string by UTF-16 unit where jaala orders by bytes. On the exercise’s code graph a count of 5,838 calls ships one row, and a page of ten functions ten. bolt.Options{LookupsOnly: true} turns pushdown off, which answers the same and ships more. A recursive rule or a graph.closure over a type’s relation walks it in one query, the server finding every node reached from the starts, where batches cost a query per level: the exercise’s reach ships its 173 facts in one walk rather than seven batches.
Apply(ctx, changes) writes an ns.Changes in one transaction, its removals before its additions, so committing a session is store.Apply(ctx, overlay.Changes()). A value keeps its kind, its unit and its spelling, an absent value is no property, and a fact’s id and citations are kept with it. A server holds a graph rather than relations, so the store refuses three things rather than bending them: a node key or endpoint that isn’t text, a second fact for one node in a label’s relation (or a second value for one property), and an argument the graph doesn’t place. It holds each fact once, as a change set names it.
bolt.Options{Namespace: "design42"} keeps a store’s graph apart from others on the same server (#435): its nodes carry JaalaNode_design42, and every query starts there, so one server can hold a host’s designs or repos side by side. stores/bolt’s tests run jaala’s generated corpus over both servers against each seed’s own facts in memory, and make exercise-graph-server STORES=neo4j,memgraph runs the guide’s questions and a code graph’s over whichever servers you name, using the pair make servers-up starts when it’s up, and otherwise starting each in Docker when no JAALA_BOLT_NEO4J or JAALA_BOLT_MEMGRAPH URL says where one already runs. Each run writes in a namespace of its own, so several checkouts share the pair.
A session over a store ¶
ns.Overlay wraps a store with a session’s edits (#288) and keeps its lookups, batches, index answers and filters. A conjunction, a walk or a summary goes to the store only when the session touched none of its relations, so a store is never asked a question its facts alone would answer wrongly. The versions page covers sessions and commits.
Versions from stores ¶
A source serving several versions of its facts can serve each one as a store of its own (ns.SourceAt), and everything above then works at a version. The versions page covers it, with Federation.Timed for stores whose histories differ.