Developer guide

Technical documentation

Use the NetlyDB SDK to embed a document database in any .NET application, register typed collections through dependency injection, store binary files in dedicated file (blob) collections, and query documents with familiar LINQ expressions or plain-text filter syntax.

Getting started

Add the NetlyDB.SDK package to your project, register NetlyDB during application startup, and inject INetlyDBCollection<T> wherever you need typed document access.

Minimal setup

// Program.cs or startup
builder.Services
    .AddNetlyDB()
    .AddNetlyDBCollection<Order>();

var app = builder.Build();

var orders = app.Services.GetRequiredService<INetlyDBCollection<Order>>();

The default collection name is derived from the document type (for example User maps to a User collection). Call AddNetlyDBCollection<T>() once per document type you want in dependency injection.

Using a collection

var id = orders.Add(new Order
{
    Reference = "SO-10042",
    CustomerName = "Contoso",
    Country = "BE",
    Status = "open",
    Total = 1299.50m
});

var order = orders.Find(id);

var openInBelgium = await orders.ListAsync(x => x.Country == "BE" && x.Status == "open");

orders.Update(id, order with { Status = "shipped" });
orders.Delete(id);

Configuration

Pass a delegate to AddNetlyDB to configure NetlyDBOptions. All options are optional; sensible defaults apply for embedded single-node use.

services.AddNetlyDB(options =>
{
    options.EnableConsoleLogs = false;
    options.EnableGrpcHosting = true;
    options.GrpcPort = 9025;
    options.ShardCount = 4;
    options.QueryLogRetention = TimeSpan.FromDays(7);
})
.AddNetlyDBCollection<Order>();
OptionDescriptionDefault
StorageRoot The directory this database lives in. Resolved once at startup, so two databases in one process cannot share files. null
Durability Synchronous acknowledges a write after its durable commit; Asynchronous acknowledges earlier. Synchronous
CommitTimeout How long a synchronous write waits for its durable commit before the outcome is reported as indeterminate. 30s
SnapshotRetention Superseded snapshot generations kept per collection (PreviousGenerations, MaxAge). 1 generation
DataClasses How replaceable each collection's data is; decides whether an unreadable artifact may be rebuilt or must stop writable startup. Critical
QueryBounds Per-query limits: parser depth, tokens, path depth, literal length, IN-list size, result-set size. generous
QueryHistoryRetention How much of a query the recent-query history keeps. Literals are replaced unless you opt in. ShapeOnly
EnableConsoleLogs Writes structured log events to the console. true
MinimumLogLevel Lowest level emitted; keeps high-frequency replication traces off by default. Information
EnableGrpcHosting Hosts the gRPC endpoint used by cluster replication and remote clients. true
GrpcPort Port for the gRPC service. 9025
BrokerHost / BrokerPort Event broker used for logging and operational events. localhost / 9023
ShardCount Number of shards for document distribution. 4
EventLogRetention How long event log entries are kept. 30 days
QueryLogRetention How long query history entries are kept. 3 days
EnableTtlCleanup Background cleanup of documents with a TTL. true
SelfAddress / Port Required when running as a cluster node. null
Security.ClusterDatabaseId The logical database every node of a cluster shares; lets a node refuse a peer from a different database. empty
NodeFocus Performance tuning mode for the node. Speed
License Enterprise license (key, file path, or env var). See Licensing. Community

Optional modules

NetlyDB is modular. Enable only what your deployment needs. Several modules are Enterprise features that require a license - see Licensing.

  • AddNetlyDBAPI(port) - self-hosted REST API (default port 9020).
  • AddNetlyDBManagementUI(webPort, apiPort) - operational Management UI (default web port 9021).
  • AddNetlyDBMCP(port, path, maxResultLimit, allowedCollections) - Model Context Protocol server for AI tooling, incl. keyword (FullTextSearch) and semantic (SemanticSearch) search tools (default port 9026).
  • JoinCluster(seedNodes) - distributed mode; contact seed nodes to join a cluster.
  • UseStorageProvider<T>(rootPath) - swap file-system storage (for example Azure Blob).

Remote client access is not available in this release. A gRPC client that replaces the local store with a remote node exists in the repository as experimental code and is not part of the supported product: it is not published in any package, and the collection it returns implements the embedded collection interface while the remote protocol covers only a small part of it. A supported remote API - one whose surface describes what the protocol actually does - is future work.

services.AddNetlyDB(options => options.EnableConsoleLogs = false)
    .AddNetlyDBCollection<User>()
    .AddNetlyDBAPI(port: 9020)
    .AddNetlyDBManagementUI(webappPort: 9021, apiPort: 9020)
    .AddNetlyDBMCP(port: 9026, path: "/mcp", maxResultLimit: 100)
    .JoinCluster("10.0.0.12", "10.0.0.13");

Named collections

Register multiple logical collections for the same document type:

services.AddNetlyDB()
    .AddNetlyDBCollection<AuditEntry>("audit")
    .AddNetlyDBCollection<AuditEntry>("security");

var provider = app.Services.GetRequiredService<INamedNetlyDBCollectionProvider<AuditEntry>>();
var audit = provider.Get("audit");
var security = provider.Get("security");

Durability & recovery

Every write travels one pipeline - admission, a bounded queue, a log sequence number, the apply, and a durable commit. The write-ahead log is framed, versioned, checksummed and ordered, and each record carries the identity of the mutation that produced it.

services.AddNetlyDB(o =>
{
    o.Durability = DurabilityMode.Synchronous;  // ack after the durable commit (default)
    o.CommitTimeout = TimeSpan.FromSeconds(30); // how long a caller waits for it

    o.SnapshotRetention.PreviousGenerations = 1;      // superseded generations to keep
    o.DataClasses.Default = DataClass.Critical;       // how replaceable this store's data is
});

When an outcome is indeterminate

A write that outlives CommitTimeout is indeterminate, which is not the same as failed: the engine stopped waiting, the mutation did not stop. Retrying is the one thing not to do, because it may already have taken effect. Resolve it by id instead - a status survives a restart, and ResolutionProcedure says what to do in one sentence.

using NetlyDB.Core.Persistence;

var users = app.Services.GetRequiredService<IDocumentCollection<User>>();

try
{
    users.Add(new User { Name = "Alice" });
}
catch (NetlyDBIndeterminateCommitException ex)
{
    // Do not retry: the mutation may already have taken effect. Resolve it instead.
    MutationStatusResult status = await users.GetMutationStatusAsync(ex.MutationId);

    if (status.IsSettled)
    {
        // status.State, status.Status!.Lsn, status.Status.DocumentApplied
    }
    else
    {
        // status.OutcomeCode         -> catalogue code, for a log line or a support ticket
        // status.ResolutionProcedure -> what to do about this answer, in one sentence
        // status.History             -> how far back the store can still answer at all
    }
}

“Not found” inside the retained history window is a real answer. Outside it, the result says so through IsOutsideRetainedHistory rather than pretending the write never existed.

Recovery

A snapshot is a generation, published atomically, not a file that is overwritten. Startup classifies what it finds on disk and replays the log over the last published generation. Ambiguity it cannot resolve stops writable startup instead of being quarantined and ignored, and every session leaves a recovery report. How aggressively an unreadable artifact may be discarded and rebuilt is set per collection by DataClasses; unclassified collections are treated as critical.

Licensing

NetlyDB is free to use as an embedded, single-node database - the Community edition. Enterprise features require a valid license: clustering & replication, the REST API, the Management UI, the MCP server, encryption at rest, JWT/OIDC authentication, and observability export. Licenses are signed and verified entirely offline - there is no license server or phone-home.

Applying a license

Provide the license token in any one of these ways (they are checked in this order):

  • In code - set options.License.LicenseKey to the license token string.
  • File path - set options.License.LicenseFilePath to a file that contains the token.
  • Environment variable - set NETLYDB_LICENSE (name configurable via options.License.EnvironmentVariable).
// 1) Inline license key (e.g. from configuration or a secret store)
services.AddNetlyDB(options =>
{
    options.License.LicenseKey = "NLDB1.eyJ2Ijox...";
});

// 2) From a license file
services.AddNetlyDB(options =>
{
    options.License.LicenseFilePath = "/etc/netlydb/netlydb.lic";
});

// 3) From an environment variable (default: NETLYDB_LICENSE) - no code required
//    setx NETLYDB_LICENSE "NLDB1.eyJ2Ijox..."
services.AddNetlyDB();

Enforcement & grace period

With no license the process runs as Community. If you enable an Enterprise feature without a valid license, startup fails closed with a clear error instead of silently running degraded. An expired license keeps working during a configurable grace period (options.License.GracePeriod, default 14 days) so renewals never cause a hard outage. You can review the active license and per-feature entitlements anytime in the Management UI under Settings → License.

Enterprise features

FeatureEnabled by
Clustering & replicationJoinCluster(...)
REST APIAddNetlyDBAPI(...)
Management UIAddNetlyDBManagementUI(...)
MCP serverAddNetlyDBMCP(...)
Encryption at restSecurity.Encryption.Enabled
Advanced auth (JWT/OIDC)Security.EnableJwtBearer
Observability exportmetrics & health endpoints

File and blob collections

Besides typed and dynamic document collections, NetlyDB provides file collections for arbitrary binary data. Each file is a FileData payload (name, extension, content type, bytes, optional virtual folder path, and tags). Blobs are persisted compressed on the configured storage provider; collection metadata and tags remain available without loading full file content.

Resolve IFileStore from dependency injection (registered automatically by AddNetlyDB). Call GetCollection(name) to open or create a collection, or CreateCollection(name) to register one explicitly (also used by the REST API and Management UI).

using NetlyDB.Core.Collections.FileCollections;
using NetlyDB.Core.Stores;

// IFileStore is registered by AddNetlyDB()
var fileStore = app.Services.GetRequiredService<IFileStore>();

fileStore.CreateCollection("uploads"); // optional; GetCollection also creates on first use
var uploads = fileStore.GetCollection("uploads");

Upload and read files

var bytes = await File.ReadAllBytesAsync("report.pdf");

var id = uploads.Add(new FileData
{
    FileName = "report.pdf",
    Extension = ".pdf",
    ContentType = "application/pdf",
    Bytes = bytes,
    VirtualFolderPath = "invoices/2026", // folder path shown in Management UI
    Tags = ["finance", "q1"]
});

// Stream decompressed content (preferred for large files)
await using var stream = uploads.OpenRead(id!.Value);

// Or load the full document (bytes populated on read)
var document = uploads.GetById(id!.Value) as FileDocument;

uploads.Delete(id!.Value);

Metadata, tags, and streaming

Use GetMetaData / GetAllMetaData to list files by name, size, content type, virtual folder, or tags without reading blob bytes. OpenRead returns a decompressed Stream for downloads and media playback; GetById loads the full FileDocument including bytes when needed.

FileMetaData meta = uploads.GetMetaData(id!.Value);
// meta.FileName, meta.Size, meta.ContentType, meta.VirtualFolderPath, meta.Tags

foreach (var entry in uploads.GetAllMetaData())
{
    Console.WriteLine($"{entry.FileName} ({entry.Size} bytes)");
}

Tag queries

Filter files by one or more tags with WhereByTagAsync or WhereByTagsAsync (match all tags by default).

var gallery = await uploads.WhereByTagAsync("gallery");
var q1Reports = await uploads.WhereByTagsAsync(
    ["finance", "q1"],
    matchAll: true);

Management UI

Enable AddNetlyDBManagementUI to manage file collections in the browser alongside documents and cluster operations. The file browser renders a folder tree from each file’s VirtualFolderPath so you can structure uploads into nested folders (set the folder when uploading or via FileData.VirtualFolderPath in code).

  • Thumbnails - images and videos show a thumbnail in the file list.
  • Video preview - hover a video thumbnail to play a short inline preview before opening the full file.
  • Tag search - filter the list by one or more tags (match all or any).
  • Upload - add files to the current collection and target folder from the UI.

REST API

When AddNetlyDBAPI is enabled, create a file collection with:

POST /Collections/file/uploads
GET  /Collections/uploads/count
GET  /Collections/names

File collections appear alongside document collections in GET /Collections/names and GET /Collections/infos.

Storage backends

By default, blobs are stored on the local file system under the NetlyDB data folder. Use UseStorageProvider<T>(rootPath) on the NetlyDB builder (for example AzureBlobStorageProvider) to persist file shards and snapshots to cloud blob storage in distributed deployments.

Collections API

INetlyDBCollection<T> is the application-facing wrapper around a typed document collection.

MemberPurpose
Add / AddRange Insert one or many documents; optional per-document TTL.
Find / FindAsync Load a document by id.
All / AllAsync Return every document in the collection.
Query() Build a fluent LINQ or string filter query.
ListAsync / FirstOrDefaultAsync / AnyAsync / CountAsync Query with LINQ or plain-text filter.
Update Replace a document by id.
UpdateWhereAsync Patch fields on all documents matching a LINQ predicate.
Delete / DeleteWhereAsync Remove by id or by LINQ predicate.
CreateIndex Declare an index from a property expression.

For projections, aggregates, and text-based update / delete statements, use the lower-level IDocumentCollection<T> (also registered in DI) via QueryAsync, SelectAsync, and related methods.

LINQ queries

The fluent Query() API composes filters with Where, sorts with OrderBy / OrderByDescending, and pages with Skip / Take. Multiple Where calls are combined with logical and.

var page = await orders.Query()
    .Where(x => x.Country == "BE")
    .Where(x => x.Total >= 100)
    .OrderByDescending(x => x.Total)
    .Skip(0)
    .Take(25)
    .ToListAsync();

Shortcut methods

These accept either a LINQ predicate or a plain-text filter string:

// LINQ predicate
var shipped = await orders.ListAsync(x => x.Status == "shipped");

// Plain-text filter (same syntax as Management UI)
var shippedText = await orders.ListAsync("Status == \"shipped\"");

var count = await orders.CountAsync("Country == \"BE\"");
var exists = await orders.AnyAsync(x => x.Total > 10_000);

Supported LINQ patterns

  • Equality and inequality: ==, !=
  • Comparisons: <, >, <=, >=
  • Logical combinations: &&, || (via chained Where or a single expression)
  • String helpers: StartsWith, EndsWith, Contains, and negation with !
  • Nested properties: x.Address.Country == "BE"
await orders.ListAsync(x => x.CustomerName.StartsWith("Cont"));
await orders.ListAsync(x => x.Status != "cancelled");
await orders.ListAsync(x => !x.Reference.EndsWith("-TEMP"));
await orders.ListAsync(x => x.Country == "BE" && x.LineCount >= 3);

Bulk updates and deletes

var updated = await orders.UpdateWhereAsync(
    x => x.Status == "open" && x.Country == "BE",
    u => u.Set(x => x.Status, "processing"));

var removed = await orders.DeleteWhereAsync(x => x.Status == "cancelled");

Indexes

Create indexes with expression selectors to speed up filters on those fields (including nested and compound keys):

orders.CreateIndex(x => x.Country);
orders.CreateIndex(x => x.Status);
orders.CreateIndex(x => new { x.Country, x.Status });

Every index - scalar, vector, or full-text - is a versioned generation tied to a checkpoint, with an explicit lifecycle. If maintenance fails, the index is marked degraded and the degradation survives a restart; the planner will not use a degraded index, so answers stay correct at the cost of a scan until it is rebuilt. Building an index does not hold the collection still.

Filters go through one semantic model whichever front end wrote them - LINQ expression, filter string, or JSON. A construct the engine cannot read is refused with a classified error rather than quietly reinterpreted, and the same query answers identically from all three.

NetlyDB includes two content-search engines alongside its LINQ/text queries, both in the free Community edition. Vector search finds documents by meaning (nearest embeddings); full-text search ranks documents by keywords (BM25).

Vector search

Index a float[] embedding and return the k nearest documents. Every index is flat (exact SIMD scoring) by default; pass VectorIndexOptions.Hnsw() to build an approximate HNSW graph index - sub-linear search with tunable recall - for large collections. The query API is identical either way. Supported metrics: cosine, dot-product, Euclidean.

// Bring-your-own embeddings on a float[] property. Flat (exact) by default...
articles.CreateVectorIndex(x => x.Embedding, dimensions: 1536, VectorMetric.Cosine);

// ...or an approximate HNSW index (sub-linear, tunable recall) for large collections:
articles.CreateVectorIndex(x => x.Embedding, dimensions: 1536, VectorMetric.Cosine,
    options: VectorIndexOptions.Hnsw());

// k-nearest-neighbour search (higher score = closer):
var hits = articles.VectorSearch(x => x.Embedding, queryEmbedding, k: 5);

// No float[] property? Use an external index and supply vectors by id:
articles.CreateExternalVectorIndex("semantic", 1536, VectorMetric.Cosine);
articles.SetVector(documentId, myVector, "semantic");

Semantic search (generated embeddings)

Reference the optional NetlyDB.Embeddings package and point it at any OpenAI-compatible endpoint (OpenAI, Azure OpenAI, Ollama, LM Studio…). An auto-embedding index backfills existing documents and embeds new writes automatically, so you can add semantic search to an existing collection in one line - or with zero code from the Management UI.

// Reference NetlyDB.Embeddings and point it at any OpenAI-compatible endpoint.
services.AddNetlyDB()
    .AddNetlyDBCollection<Article>()
    .AddNetlyDBEmbeddings(o =>
    {
        o.BaseUrl = "https://api.openai.com/v1"; // or Ollama / Azure / LM Studio
        o.Model   = "text-embedding-3-small";
        o.Dimensions = 1536;
    });

// One line adds semantic search to an existing collection - no float[] property needed.
// NetlyDB backfills existing documents and keeps the index current as documents change.
articles.CreateAutoVectorIndex("semantic", embedder, x => x.Title, x => x.Content);

var hits = await articles.SemanticSearchAsync("how do I rotate keys?", "semantic", embedder, k: 5);

Full-text search

BM25-ranked keyword search over one or more text fields, backed by an inverted index with stop-word filtering. The query language supports plain terms (OR), +required (AND), -excluded (NOT), and "exact phrases".

// BM25 keyword index over one or more text fields.
articles.CreateFullTextIndex(x => x.Title, x => x.Content);

// Ranked keyword search. Operators: +required, -excluded, "exact phrase".
var hits = articles.FullTextSearch("rotate +encryption -legacy", k: 10);

// Phrase match (adjacent terms only):
var exact = articles.FullTextSearch("\"encryption keys\"", k: 10);

Both engines are also reachable from the Management UI (the Indexes and Search pages) and, when the MCP server is enabled, from AI agents via the FullTextSearch and SemanticSearch tools.

Plain-text query syntax

Pass a filter string to ListAsync, FirstOrDefaultAsync, CountAsync, AnyAsync, or Query().Where(string). Syntax matches the Management UI query language.

Filter operators

OperatorExample
== Country == "BE"
!= Status != "cancelled"
>, >=, <, <= Total >= 100 && LineCount < 5
&& Country == "BE" && Status == "shipped"
like CustomerName like "Acme%"
not like Reference not like "TMP%"
in Country in ("BE", "NL", "DE")
skip / limit Status == "open" skip 20 limit 10
order by Country == "BE" order by Total desc limit 25

Examples

Single property

Match one field to a literal value.

Country == "BE"
await orders.ListAsync("Country == \"BE\"");

Multiple conditions

Combine predicates with &&.

Country == "BE" && Status == "shipped" && Total >= 500
await orders.Query()
    .Where("Country == \"BE\" && Status == \"shipped\" && Total >= 500")
    .ToListAsync();

Wildcard search

Use like with % as in SQL.

CustomerName like "Net%"
await orders.FirstOrDefaultAsync("CustomerName like \"Net%\"");

Paging

Skip and limit can appear in any order after the filter.

Status == "open" skip 10 limit 25
await orders.ListAsync("Status == \"open\" skip 10 limit 25");

Sorting

Order results before paging when using string queries on the collection.

Country == "BE" order by Total desc limit 10
// Prefer Query().OrderBy(...) for LINQ; order by is built into text queries on IDocumentCollection.

SELECT queries

Use IDocumentCollection<T>.SelectAsync or QueryAsync when you need field projection, aggregates, grouping, or raw rows without document metadata wrappers.

var collection = app.Services.GetRequiredService<IDocumentCollection<Order>>();

// Project fields
var rows = await collection.SelectAsync(
    "select Reference, CustomerName, Total where Country == \"BE\" limit 50");

// Raw rows (no document metadata wrapper)
var raw = await collection.SelectAsync(
    "select raw Reference, Total where Status == \"open\"");

// Aggregates
var stats = await collection.SelectAsync(
    "select Country, sum(Total) as Revenue, count(*) as OrderCount group by Country");

Clauses can be combined: where, order by, group by, skip, and limit.

UPDATE and DELETE queries

Text mutations run through QueryAsync on IDocumentCollection<T>:

var collection = app.Services.GetRequiredService<IDocumentCollection<Order>>();

await collection.QueryAsync(
    "update set Status = \"archived\" where Country == \"BE\" && CreatedAt < DateTime(\"2024-01-01T00:00:00Z\")");

await collection.QueryAsync("delete where Status == \"cancelled\"");

delete * truncates the entire collection. All other deletes require an explicit where clause.

Document metadata

Filter on system fields with the Meta. prefix:

await orders.ListAsync("Meta.CreatedAt >= DateTime(\"2025-01-01T00:00:00Z\")");

// Meta.Id uses the document identifier string
await orders.FirstOrDefaultAsync("Meta.Id == \"b549ea4b-0040-4d16-9dfe-0c6c06ca16fa\"");

Change feed

Subscribe to document mutations in-process for cache invalidation, projections, and reactive workflows. Publishing never blocks writers; a lagging subscriber drops the newest changes (and can reconcile). Resolve IChangeFeed from dependency injection.

var changeFeed = app.Services.GetRequiredService<IChangeFeed>();

using var subscription = changeFeed.Subscribe(
    new ChangeFeedSubscriptionOptions { CollectionName = "User" }); // omit for all collections

await foreach (var change in subscription.Reader.ReadAllAsync(cancellationToken))
{
    // change.Operation (Insert / Update / Delete / Truncate / IndexChanged)
    // change.CollectionName, change.DocumentId, change.Document, change.Sequence
}

Backup, export & import

Export a collection to a portable, identity-preserving JSON document and restore it later (or into a new collection). Works programmatically, from the Management UI Collections page, or directly from a mobile/MAUI app without the REST API or UI. Import supports Replace (clear first) and Merge (upsert by id), and recreates the collection if it doesn't exist.

using NetlyDB.Core.Persistence;

// Export a collection to portable, identity-preserving JSON.
using var file = File.Create("users.netlydb.json");
CollectionBackup.Export(docStore, "User", file);

// Import (Replace clears first; Merge upserts by id). Recreates the collection if needed.
using var restore = File.OpenRead("users.netlydb.json");
var result = CollectionBackup.Import(docStore, "User", restore, ImportMode.Replace);

Exports are plaintext (decrypted) JSON by design - treat the files as sensitive regardless of encryption-at-rest.

Schema validation

Attach opt-in per-collection field rules; local writes that violate them are rejected with a SchemaValidationException. Collections without a schema are unaffected and pay no overhead. Each rule supports Required, a Type (String / Integer / Number / Boolean), MinLength / MaxLength, Min / Max, a regex Pattern, and AllowedValues.

services.AddNetlyDB(o =>
{
    o.Validation.Collections["Person"] = new CollectionValidationSchema
    {
        Rules =
        [
            new FieldValidationRule { Path = "Name",   Required = true, Type = FieldValueType.String, MaxLength = 40 },
            new FieldValidationRule { Path = "Age",    Type = FieldValueType.Integer, Min = 0, Max = 130 },
            new FieldValidationRule { Path = "Status", AllowedValues = ["active", "inactive"] }
        ]
    };
})
.AddNetlyDBCollection<Person>();

// A write that violates the rules throws SchemaValidationException;
// over the REST API it surfaces as a 400 with the field violations.

Compression at rest

Transparent at-rest compression of snapshots, the write-ahead log, and vector data. It composes with encryption (compress-then-encrypt), and reads always decompress automatically - so data stays readable even after you disable it. Handy on storage-constrained targets like mobile.

services.AddNetlyDB(o =>
{
    o.Compression.Enabled = true;                        // compress new writes at rest
    o.Compression.Algorithm = CompressionAlgorithm.GZip; // or Brotli (smaller, more CPU)
    o.Compression.MinimumSizeBytes = 1024;               // store tiny blobs uncompressed
});

Clustering & replication

JoinCluster(seedNodes) turns a node into a cluster member (an Enterprise feature). Replication is peer-to-peer with last-write-wins, which is worth stating plainly: a concurrent update to the same document on two nodes loses one of the two values. Read what replication guarantees and what it does not in the repository before relying on it.

What the protocol does for you

  • Versioned and negotiated - nodes agree a protocol version and report the guarantee set they settled on.
  • Provenance on every change - which store, which run, which change; a change delivered twice is recognised as the same change.
  • Persisted progress - a receiver remembers what it has applied, and a durable outbox can say what is still owed after a crash.
  • Lifecycle-aware - replication asks the engine whether it may write, rather than writing into a state that does not admit writes.
  • One database - set Security.ClusterDatabaseId to the same value on every intended member and a node refuses a peer from a different database instead of merging two stores that happen to share collection names.

Rebuilding a replica

A replica whose local state is unrecoverable is rebuilt from a named peer, and only when an operator asks for it with explicit intent. There is no path that arrives at a full resynchronisation on its own - discarding a node's state is a decision, not a recovery step.

Operations & diagnostics

Every correctness-critical state is diagnosable from outside the process, and no diagnostic surface carries a document value, a credential, or key material - that is enforced by a test harness that seeds sentinels and asserts none of them appear anywhere.

Health

Five projections, each registered as its own check with its own tag, so an orchestrator can wire one probe per question instead of reading one composite answer. Each is cheap by contract: it reads lifecycle state and never scans or repairs.

builder.Services.AddHealthChecks().AddNetlyDB();

// One probe per question, selected by tag:
app.MapHealthChecks("/health/live",     new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/health/startup",  new() { Predicate = c => c.Tags.Contains("startup") });
app.MapHealthChecks("/health/ready",    new() { Predicate = c => c.Tags.Contains("ready") });
app.MapHealthChecks("/health/readable", new() { Predicate = c => c.Tags.Contains("readable") });
app.MapHealthChecks("/health/writable", new() { Predicate = c => c.Tags.Contains("writable") });
  • live - is the runtime alive enough that restarting it would not help?
  • startup - has initial startup finished, successfully or not?
  • ready - may ordinary application traffic use this database?
  • readable / writable - are reads, or writes, admitted right now?

Diagnostic codes & metrics

Every classified outcome has a stable code (NDB-LIFE-…, NDB-WAL-…, NDB-REC-…, NDB-IDX-…, NDB-SNAP-…, NDB-REPL-…, NDB-STOR-…, NDB-STAT-…) carrying its meaning, the operator action, and a specification reference. Metrics live under the NetlyDB meter and cover the mutation pipeline, WAL and group commit, snapshots, recovery, indexes, queries, replication, storage, memory and lifecycle. Both catalogues are append-only published contracts: a code's meaning and an instrument's name, unit and labels never change once released, because dashboards, alert rules and runbooks are keyed on them. Label sets are bounded - no instrument carries an identifier-valued label, so cardinality cannot grow with your data.

Diagnostic events are emitted outside every correctness-critical lock, so a slow or throwing sink degrades observability and never blocks a write.

Explaining a query

var result = await orders.WhereAsync("Country == \"BE\" && Total >= 500");

var explanation = result.Explanation;
// Which index answered it, which indexes were refused and why,
// whether it fell back to a scan, whether any part ran approximately.
// Index names and plan shape only - never a literal or a document value.

// Over REST:
// POST /Collections/Order/explain   body: "Country == \"BE\" && Total >= 500"

Query safety limits

services.AddNetlyDB(o =>
{
    o.QueryBounds = o.QueryBounds with { MaxInListSize = 1_000, MaxResultSetSize = 50_000 };

    // Recent-query history keeps query shapes, not literals, unless you opt in.
    o.QueryHistoryRetention = QueryHistoryRetention.ShapeOnly;
});

Audit trail

Destructive and ownership-affecting actions - torn-tail repair, quarantine, generation pruning, collection deletion, ownership acquisition and release, index drop, truncate - are recorded in an append-only, checksum-chained audit log kept separately from the diagnostic log, with the actor where one is known.

Reading a store that will not open

The CLI's inspect group reads a store's diagnostic artifacts straight from disk without starting the engine, because the stores worth inspecting are the ones that will not start. It is inspection only - there are no repair verbs, since a repair needs exclusive lifecycle control and store ownership and must leave an audit record, none of which a tool pointed at a directory can establish.

# Reads the store's diagnostic artifacts from disk, without starting the engine.
netlydb inspect --store ./data collections
netlydb inspect --store ./data wal Orders
netlydb inspect --store ./data wal verify Orders
netlydb inspect --store ./data snapshot Orders
netlydb inspect --store ./data recovery
netlydb inspect --store ./data audit verify --json

From code

MemberPurpose
GetMutationStatusAsync(Guid) Resolve what happened to one write, by mutation id, including after a restart. On IDocumentCollection<T> and IFileCollection.
QueryResult.Explanation How a query was answered: index used, indexes refused and why, scan fallback, approximate execution.
GetIndexDefinitions / IndexInfo Each index's kind, lifecycle state, generation, definition version and property paths.
NodeReplicationStatus Queue depth, active peers, retrying work, bootstraps in progress, negotiated guarantees, last error.
RecoveryReport / SnapshotReport What the last recovery concluded, and what each snapshot attempt published.

The Management UI surfaces the same values on its Diagnostics page (engine state and degradation scope, recovery and snapshot reports, mutation lookup by id), on Indexes (lifecycle state and degradation reason) and on Nodes (peer state and replication lag). It is read-only: there are no repair actions in the UI.

Solution packages

  • NetlyDB.SDK - dependency injection extensions, INetlyDBCollection<T>, cluster and remote client wiring.
  • NetlyDB.Core - document engine, file collections, query pipeline, indexing, persistence, replication.
  • NetlyDB.API - optional REST API host (document and file collection endpoints).
  • NetlyDB.MCP - optional MCP server for AI assistants.
  • NetlyDB.Licensing - offline license verification and Enterprise-feature gating (bundled with the SDK).
An unhandled error has occurred. Reload X