Skip to content

Use as a library

There are two ways to use Fallen-8. Most of this site describes the first: run it as a service and talk to it over REST. The second is to reference the engine as a .NET library and hold the graph inside your own process, with no HTTP, no serialization, and no server to operate. That is what this page covers.

The library is the same engine the service runs. Everything in graph model, path finding, subgraphs, indexes, analytics and save games is reachable in-process; the REST layer is a thin projection on top of it.

The engine is published as the Fallen-8 package on nuget.org and to GitHub Packages, from .github/workflows/release.yml on every v*.*.* tag:

dotnet add package Fallen-8

It targets net10.0, is MIT-licensed, and takes four dependencies: Microsoft.Extensions.Logging, Microsoft.Extensions.Caching.Memory, System.IO.Hashing and System.Numerics.Tensors. The root namespace is NoSQL.GraphDB.Core; the types you touch most live in NoSQL.GraphDB.Core, NoSQL.GraphDB.Core.Model and NoSQL.GraphDB.Core.Transaction.

The package declares Server GC with concurrent collection, because a resident graph is exactly the workload Server GC is built for. A non-web host would otherwise default to Workstation GC.

In the engine (this package) Not in the engine
Graph model, transactions, indexes, path and subgraph algorithms, analytics The REST API, its versioning, and the OpenAPI document
Persistence: checkpoints, the write-ahead log, save games The API key and every other perimeter control (security)
Element embeddings and the vector index The text-in embedding provider and the chat gateway, which are apiApp-only
The change feed Document ingestion and the NLP sidecar
Plugin discovery and the IPlugin contracts The Roslyn compilers behind dynamic fragments (see below)
One graph per Fallen8 instance Namespaces, which are an apiApp-level collection

Dynamic C# is a good illustration of the split. The engine declares the abstractions ISubGraphRecipeCompiler, IStoredQueryCompiler and IPluginCompiler, and takes them as constructor arguments; the Roslyn implementations live in the apiApp (Helper/PluginCompiler.cs and friends). A library host that never needs compiled-at-runtime fragments passes nothing and simply calls the strongly typed algorithms directly.

Because a Fallen8 object is one graph, the library equivalent of several namespaces is several Fallen8 instances. The apiApp’s collection, its catalog and its /ns/{ns} routing are not part of the package.

The pattern is always the same: build a transaction, enqueue it, wait for it, then read.

using Microsoft.Extensions.Logging;
using NoSQL.GraphDB.Core;
using NoSQL.GraphDB.Core.Transaction;
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var fallen8 = new Fallen8(loggerFactory);
var creationDate = Convert.ToUInt32(DateTimeOffset.Now.ToUnixTimeSeconds());
// A -> B -> C, created in one atomic transaction.
var verticesTx = new CreateVerticesTransaction();
verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "A" } });
verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "B" } });
verticesTx.AddVertex(creationDate, "node", new Dictionary<string, object> { { "name", "C" } });
fallen8.EnqueueTransaction(verticesTx).WaitUntilFinished();
var vertices = verticesTx.GetCreatedVertices();
var edgesTx = new CreateEdgesTransaction();
edgesTx.AddEdge(vertices[0].Id, "connects", vertices[1].Id, creationDate, "link");
edgesTx.AddEdge(vertices[1].Id, "connects", vertices[2].Id, creationDate, "link");
fallen8.EnqueueTransaction(edgesTx).WaitUntilFinished();
Console.WriteLine($"{fallen8.VertexCount} vertices, {fallen8.EdgeCount} edges");

AddEdge takes the edge type ("connects") before the target and the label ("link") after the creation date. They are different things and the argument order does not warn you: edge type vs label.

Note that the created vertices come back from the transaction object (verticesTx.GetCreatedVertices()), not from the engine. The transaction is the record of what it did.

Reads go straight at the engine through IFallen8Read. They need no transaction and no lock: the graph is published copy-on-write, so a reader always sees a consistent snapshot.

Member Returns
VertexCount, EdgeCount Live element counts
TryGetVertex, TryGetEdge, TryGetGraphElement One element by id, false when absent or removed
GetAllVertices, GetAllEdges, GetAllGraphElements Every element, optionally filtered to one label
GraphScan Elements whose named property satisfies a typed comparison
GraphScanAllProperties Elements where any property value contains a search term
IndexScan, RangeIndexScan Point and range lookups against a named index
FulltextIndexScan, VectorIndexScan Fulltext hits with scores, and nearest-neighbour vector hits

These follow the repository-wide Try*(out result, ...) : bool convention: an absent element or an unknown index is false, not an exception.

Every mutation is a transaction. EnqueueTransaction hands it to the single writer and returns a TransactionInformation; WaitUntilFinished() blocks until it commits or rolls back, and GetTransactionState(txId) polls instead.

The built-in transactions live in NoSQL.GraphDB.Core.Transaction:

Purpose Transactions
Create CreateVertexTransaction, CreateVerticesTransaction, CreateEdgeTransaction, CreateEdgesTransaction
Properties AddPropertyTransaction, AddPropertiesTransaction, RemovePropertyTransaction
Remove RemoveGraphElementTransaction, RemoveGraphElementsTransaction, TabulaRasaTransaction, TrimTransaction
Subgraphs CreateSubGraphTransaction, RemoveSubGraphTransaction
Registration RegisterPluginTransaction, RemovePluginTransaction, RegisterStoredQueryTransaction, RemoveStoredQueryTransaction
Embeddings SetEmbeddingsTransaction
Persistence SaveTransaction, LoadTransaction
Your own DelegateTransaction (below)

A batch transaction is all or nothing: CreateVerticesTransaction with 10,000 vertices either commits every one or leaves the graph untouched. On a rollback the engine records why, as TransactionFailureReason:

Reason Means
None The default, and the value on a committed transaction
InvalidInput Structurally invalid, for example a malformed subgraph pattern
NotFound A referenced element did not exist, for example a missing edge endpoint
Conflict Conflicts with current state, for example a name already in use
QuotaExceeded A count or materialized-element ceiling was hit
InternalError An unexpected fault, including an exception that escaped execution

The REST layer maps these onto 400, 404, 409 and 500; in-process you read the reason directly.

Terminal transaction state is retained for the most recent 100,000 transactions. Past that, the oldest ids are dropped and GetTransactionState reports them as not existing. Long-lived hosts should therefore not treat a transaction id as a permanent handle.

Multi-step writes with DelegateTransaction

Section titled “Multi-step writes with DelegateTransaction”

When a write needs logic that no built-in transaction expresses, DelegateTransaction runs your body on the writer thread against an IFallen8WriterContext. That context is the sanctioned mutation surface: CreateVertex, CreateVertices, TryCreateEdge, SetProperty and RemoveProperty, all reversible if the body throws.

One asymmetry matters. A removal performed inside the body is not undone by a rollback, because the soft-delete model has no generic un-remove. A body that only creates and sets properties is fully reversible; a body that also removes elements has a weaker guarantee. Split the removal into its own transaction when that matters.

Single-threaded hosts, such as browser WebAssembly

Section titled “Single-threaded hosts, such as browser WebAssembly”

By default the engine owns a dedicated writer thread: enqueuing hands the transaction over, and the writer drains whatever is ready into one commit group with one fsync. Some hosts cannot start a thread at all. A single-threaded WebAssembly runtime in the browser is the usual case, where Thread.Start throws PlatformNotSupportedException. There, the engine applies each transaction inline on the calling thread instead:

var fallen8 = new Fallen8(loggerFactory); // no special argument needed
// In a single-threaded host this reports Inline; on a server, Threaded.
Console.WriteLine(fallen8.TransactionExecution);
var info = fallen8.EnqueueTransaction(tx);
// Already terminal: the write happened inside the call above.
Assert(info.Completion.IsCompleted && info.TransactionState == TransactionState.Finished);

The mode is detected at runtime, so the same package works in both places with no compile-time switch and no host configuration. TransactionExecutionMode states it explicitly when you would rather assert than detect: Threaded fails loudly on a host that cannot start the writer, Inline forces inline execution anywhere.

In inline mode Behaviour
EnqueueTransaction Applies, flushes and completes the transaction before returning
WaitUntilFinished(), await Completion Return immediately; there is nothing left to wait for
Ordering, rollback, GetTransactionState Unchanged; the same execution path runs
Write-ahead log, change feed Both work; each transaction is its own commit group, so one fsync each
Concurrent callers Serialized, so the single-writer invariant holds even if the host has threads
Group commit Given up; it was the only thing the queue was buying

To put a face on an in-browser graph, Studio’s canvas component renders it straight from your interop layer; the walk-through from a WASM engine to the canvas to the full embedded Studio is Embed scenarios.

Checkpoints work here too. A save fans its sidecar writes out across the thread pool and then waits for them, and a load reads its partitions in parallel, neither of which a single-threaded host can do, because the one thread is the one waiting. Both now run inline instead, chosen by the same runtime probe, so SaveTransaction and LoadTransaction complete on a browser host as well (verified writing into the Emscripten virtual filesystem and reading the graph back). Getting those bytes out of the virtual filesystem and into somewhere durable (IndexedDB, a download, your own API) is the host’s job, not the engine’s.

One thing the host has to do for itself. Plugin lookup by name finds its type by enumerating the assemblies in the application’s base directory, and a browser has none there, so every name is a clean miss - which used to mean no index and no vector search in a browser at all. Register the types you need and the names resolve again: see Registering plugin types when discovery cannot help below.

Registering plugin types when discovery cannot help

Section titled “Registering plugin types when discovery cannot help”

Indexes, algorithms, graph functions and services are all plugins, and the engine normally finds one by its PluginName while enumerating the *.dll files under AppContext.BaseDirectory. Two hosts get nothing out of that. A browser-wasm host has no dll files there at all, because the assemblies are packaged as WebCIL inside the app bundle. A trimmed host may no longer contain a type that exists only as a string. Both fail the same clean way: the name is not found.

RegisterPluginType<T> closes that. The host hands the engine the types it wants reachable by name, and resolution consults this engine’s registry before it tries discovery:

using NoSQL.GraphDB.Core.Index;
using NoSQL.GraphDB.Core.Index.Vector;
var fallen8 = new Fallen8(loggerFactory);
fallen8.RegisterPluginType<DictionaryIndex>();
fallen8.RegisterPluginType<VectorIndex>();
// The plugin name now resolves out of the registry, with nothing scanned.
fallen8.IndexFactory.TryCreateIndex(out var vectors, "embeddings", "VectorIndex",
new Dictionary<string, object> { { "dimension", 3 } });

T is a type argument, not a string, so it travels straight to the Activator call: nothing is scanned, nothing is compiled, and the member carries no trim warning. What the contract requires:

Rule Detail
Exactly one contract T implements one of IShortestPathAlgorithm, ISubGraphAlgorithm, IGraphAnalyticsAlgorithm, IGraphFunction, IIndex, IService. Zero or two: InvalidInput
A public parameterless constructor Required by the new() constraint, so a type the engine could not activate is a compile error at your call site
The name comes from the instance Registration probes one instance for its PluginName and Description. There is no name parameter, so nothing can disagree with the type
It is a transaction Returns TransactionInformation; an invalid name is InvalidInput, a duplicate Conflict, the registration ceiling QuotaExceeded
Registry first A registered type shadows a same-named discovered one, which is the point in a host where the built-in cannot be discovered
Per engine The registry belongs to one Fallen8 instance, like stored queries. A host with several graphs registers per instance

A host registration is never persisted, deliberately. There is no source to store, so neither a checkpoint nor the write-ahead log carries one, and the commit reports Durable true because there is nothing to write. The consequence is a rule for the host: register your types on every start, and do it before a LoadTransaction, because a load rehydrates each saved index by its plugin name. Registrations already in place survive a load rather than being replaced by it, so the order is register, then load.

These claims are gated rather than asserted. The repository carries a trimmed browser-wasm probe, tools/browser-probe, which runs headless under node in CI and fails the build if a thread can start on that runtime, if index creation succeeds without a registration or fails with one, if the vector search misses, if a checkpoint round trip loses a host-registered index or its content, or if a load wipes a registration.

The constructor you pick decides what the instance carries:

Constructor Gives you
new Fallen8(loggerFactory) A pure in-memory graph: no WAL, no change feed, nothing on disk
new Fallen8(path, loggerFactory) An instance loaded from an existing checkpoint at path
new Fallen8(loggerFactory, changeFeedOptions) The change feed as an in-order event stream
new Fallen8(loggerFactory, writeAheadLogOptions, ...) A write-ahead log plus the optional compilers

The widest overload also takes ISubGraphRecipeCompiler, IStoredQueryCompiler, IPluginCompiler, a ChangeFeedOptions, a metrics scope id and a TransactionExecutionMode, so a host can opt into exactly the capabilities it wants. Snapshots are written and read with SaveTransaction and LoadTransaction, in the same on-disk format the service uses, which means a graph saved in-process can be loaded by the service and the other way round.

The package is marked trim-compatible, so a consumer can publish with PublishTrimmed=true (even TrimMode=full) and needs no TrimmerRootAssembly entry for it. Measured once on a browser-wasm consumer, that took the engine assembly from 412 KB to 166 KB and the whole managed payload from 3.19 MB to 2.02 MB - a reading of one build at one point in time, not a promise about yours.

What survives trimming, and what the compiler will warn you about:

Trim-safe Warns at your call site (IL2026)
Constructing an in-memory engine, the mutation transactions, every read and scan Naming a path or analytics algorithm by string: TryCalculateShortestPath(out paths, "BLS", …), TryRunAnalytics
TryCalculateShortestPath<T> - the algorithm as a type argument CreateSubGraphTransaction (its algorithm is a plugin name)
SubGraphFactory.TryCreateSubGraph<T> Asking what discovery can see: GetAvailableIndexPlugins, GetAvailableSubGraphPlugins, GetAvailableServicePlugins
RegisterPluginType<T>, and creating an index or a service by name once its type is registered SaveTransaction / LoadTransaction, and DelegateJson
Indexes and vector search you have already created, and the change feed new Fallen8(path, loggerFactory) and the write-ahead-log constructor overload

The rule behind the table: a type the engine reflectively constructs from a type argument can be kept by the trimmer, so those paths are annotated and safe. A type that exists only as a string - resolved by scanning assemblies, or read out of a checkpoint - cannot be, so those members declare [RequiresUnreferencedCode] and tell you at build time rather than failing at runtime. Index and service creation are named by string yet do not warn, because they resolve a registered type first, which involves no reflection over strings at all; their discovery fallback sits behind one narrow suppression rather than a warning handed to every caller. If you trim and call one anyway, the two halves of that column fail differently. A lookup by name degrades quietly: discovery finds nothing, so the call returns false. A checkpoint or delegate path fails loudly, because a payload-named type that no longer resolves is corrupt data rather than a miss: the checkpoint reader resolves types with throwOnError: true, so LoadTransaction rolls back with InternalError and the exception on TransactionInformation.Error; DelegateJson deserialization throws; and a spatial index whose metric type cannot be resolved raises InvalidDataException, which the load logs and skips so the rest of the checkpoint still arrives.

A browser build sharpens the column. There, discovery finds no assemblies at all, so every lookup that resolves a name by scanning is a guaranteed miss whether you trim or not, and the answer is to register the types instead. The checkpoint entry is the one that stays: SaveTransaction and LoadTransaction still carry the requirement, because a checkpoint stores property values it resolves reflectively on load, so a browser host that saves and loads suppresses IL2026 at its own call site - which is exactly what the committed probe does.

The package version is not decorative: it tracks breaking changes to the engine’s public types, which are recorded in fallen-8-core.csproj. The REST contract was unchanged by all of them.

Version Breaking change
0.1.0 VertexModel.OutEdges/InEdges became read-only views, and TryGetOutEdge/TryGetInEdge return IReadOnlyList<EdgeModel> instead of immutable collections
0.2.0 GetAllVertices/GetAllEdges/GetAllGraphElements and the index scans return IReadOnlyList<T> instead of ImmutableList<T>, dropping the per-call AVL tree
0.3.0 IIndex gained a required Boolean SupportsPointEqualityLookup; every built-in index implements it, an external implementation must add it

In each case the common members (.Count, the indexer, foreach, LINQ) were unaffected: only members specific to the immutable collections were lost.