HoloDb manual

An embeddable holographic database for .NET. It stores data as one associative store — queried by key with SQL, by content with similarity search, and by aggregate — is transactional (ACID with a write-ahead log), durable and larger-than-memory when you need it, and small enough to run in a browser tab. This page is the practical guide: install it, put data in, get answers out.

Install

Add the package from NuGet:

dotnet add package EvaluatedApplications.HoloDb

It targets .NET 8+ and is dependency-light. The package ships the compiled library only, and every capability is free to use.

Quick start

Create a service, run some SQL, read the rows back. This is the whole surface for most apps:

using HoloDb;

var db = new HoloDbService(new HoloDbOptions());   // in-memory

db.Execute("CREATE TABLE users (id INT PRIMARY KEY, age INT, name TEXT)");
db.Execute("INSERT INTO users VALUES (1, 30, 'Ada'), (2, 24, 'Linus')");

var result = db.Execute("SELECT name FROM users WHERE age >= 18 ORDER BY age DESC");
foreach (var row in result.Rows)
    Console.WriteLine(row["name"]);

Everything runs synchronously through Execute. There is also ExecuteAsync(sql, ct), which offloads the work to the thread pool so a request thread isn't blocked:

var result = await db.ExecuteAsync("SELECT COUNT(*) FROM users");

In-memory or durable

By default the database lives in memory and is gone when the process ends. To make writes durable, give it a write-ahead log path:

var db = new HoloDbService(new HoloDbOptions { WalPath = "app.wal" });

Durable state is a compact snapshot of the current data plus a short log of the writes since the last checkpoint. Recovery is bounded by how much data you hold, not by how long the database has been running — reopening a long-lived database stays fast because it loads a snapshot and replays only the recent tail, never the entire history.

The log is folded into a fresh snapshot automatically once it passes a threshold (CheckpointThresholdBytes, default 8 MB), and you can force one — for example before a clean shutdown:

db.Checkpoint();   // fold the log into a snapshot and truncate it
In WebAssembly (a browser tab) there is no file system, so leave WalPath null and use the in-memory engine — which is exactly what the Analyst demo does.

If you want the raw engine directly (no async wrapper, no licensing surface), open it yourself:

Database engine = Database.Open("app.wal");   // or Database.Open(null) for in-memory
engine.Execute("...");

The HoloDbService.Database property exposes the same engine underneath the service.

Durable & larger-than-memory

The default engine keeps everything in RAM (durable via the snapshot + WAL above). When a table needs to outgrow memory, create it as a paged table: it lives on an 8 KB buffer-pool page store with a redo-only WAL, so cold pages spill to disk and the table can exceed RAM — while the constant-time aggregates stay maintained on disk.

// create a durable, larger-than-memory table backed by a directory
db.CreatePaged("CREATE TABLE events (id INT PRIMARY KEY, kind TEXT, value INT)", dir: "data/events");

db.PagedBulkLoad("events", rows);              // stream rows in; commits every N (default 4096)
db.Execute("SELECT kind, COUNT(*), SUM(value) FROM events GROUP BY kind");

// reopen it later — crash recovery is bounded, a torn tail is dropped
db.OpenPaged("data/events");

The buffer-pool size (CreatePaged's bufferFrames, default 8192 × 8 KB) bounds how much stays resident. The one structure that scales with row count is the primary-key index, which stays in RAM (~16 bytes/row) — the limiting resource for tables far larger than memory.

Tables & types

Three storage types, each with the usual SQL aliases:

TypeAliases.NET
INTINTEGERlong
REALFLOAT, DOUBLE, DECIMAL, NUMERICdouble
TEXTVARCHAR, STRINGstring
CREATE TABLE sales (
  id     INT PRIMARY KEY,
  region TEXT,
  amount REAL
)

One column may be PRIMARY KEY; it is enforced unique.

DECIMAL/NUMERIC map to REAL (a 64-bit float), so they are not exact decimals. For money, store integer minor units — cents as an INT — which is exact and also lets the constant-time integer aggregates serve your revenue totals.

Inserting data

Single or batched rows, with or without a column list:

db.Execute("INSERT INTO sales VALUES (1, 'EU', 42.50)");
db.Execute("INSERT INTO sales (id, region, amount) VALUES (2, 'US', 17.00), (3, 'EU', 61.10)");

Strings use single quotes; escape a quote by doubling it ('O''Neil'). UPDATE and DELETE with a WHERE clause work as expected:

db.Execute("UPDATE sales SET amount = 0 WHERE region = 'US'");
db.Execute("DELETE FROM sales WHERE amount = 0");

Bulk loading

For large loads, skip the SQL parser entirely and hand the engine typed column arrays. This is the fast path — one array per column (long[], double[], or string[]):

var ids     = new long[]   { 1, 2, 3 };
var regions = new string[] { "EU", "US", "EU" };
var amounts = new double[] { 42.50, 17.00, 61.10 };

db.BulkLoad("sales", new[] { "id", "region", "amount" },
            new Array[] { ids, regions, amounts }, count: 3);

It is atomic and validates primary-key uniqueness before committing. Load in chunks (e.g. 100k rows) for very large datasets — that is how the Analyst ingests a big paste without stalling the browser.

Querying (the dialect)

HoloDb speaks a substantial SQL dialect. A SELECT supports:

SELECT region, COUNT(*), SUM(amount), AVG(amount)
FROM sales
WHERE amount BETWEEN 10 AND 1000
GROUP BY region
HAVING SUM(amount) > 50
ORDER BY sum(amount) DESC
LIMIT 10

A join across two tables:

SELECT o.id, c.name, o.total
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
WHERE o.total > 100
ORDER BY o.total DESC

Reading results

Execute returns a QueryResult. The simplest way to read it is row-by-row as dictionaries keyed by column name:

var r = db.Execute("SELECT region, COUNT(*) FROM sales GROUP BY region");
foreach (var row in r.Rows)
    Console.WriteLine($"{row["region"]}: {row["count(*)"]}");

Aggregate columns are named by the function applied: count(*), sum(amount), avg(amount), min(amount), max(amount). Use r.Columns to enumerate the result's column names in order (handy for building a table generically), and for a write statement r.Affected is the number of rows changed.

For hot paths, QueryResult also keeps results columnar and exposes direct accessors so you can read values without materialising a dictionary per row.

Transactions

ACID transactions with explicit control. Wrap statements in BEGINCOMMIT, or discard with ROLLBACK:

db.Execute("BEGIN");
try
{
    db.Execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    db.Execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
    db.Execute("COMMIT");
}
catch
{
    db.Execute("ROLLBACK");
    throw;
}

With a WalPath set, a committed transaction is durable — it survives a crash. On restart the engine loads the last snapshot and replays only the committed writes logged since it, so recovery cost tracks live data, not history.

Similarity search

Because rows are stored holographically, HoloDb can do content-addressable retrieval: rank rows by similarity to a partial key, no separate vector index to build. Use the NEAREST clause with the fields you know and a LIMIT for how many neighbours you want:

SELECT id, title
FROM articles
NEAREST (title = 'holographic database')
LIMIT 5

This returns the five rows whose content is closest to the query, ordered by similarity — SQL and vector-style retrieval from the same engine, over the same rows.

Similarity is exact below a few thousand rows and uses a sublinear index above that (in-memory engine). NEAREST can't yet be combined with WHERE, GROUP BY, or a join.

Indexes & why aggregates are instant

Add a sorted index on a column to speed range and lookup queries:

CREATE INDEX ON sales (region)

You rarely need indexes for counting, though: COUNT(*) and SUM over integer columns are served from maintained accumulators in constant time, independent of how many rows the table holds. That is the source of HoloDb's benchmark wins — it doesn't scan to count.

Dependency injection

Register HoloDb in a .NET host and inject HoloDbService wherever you need it:

builder.Services.AddHoloDb(options =>
{
    options.WalPath = "app.wal";
});

// then, in a controller / service:
public class ReportService(HoloDbService db)
{
    public Task<QueryResult> Totals() =>
        db.ExecuteAsync("SELECT region, SUM(amount) FROM sales GROUP BY region");
}

EvalApp pipelines

HoloDb runs inside an EvalApp harness, so heavy queries execute as compiled, resource-gated, self-tuning pipelines. Attach a service to a pipeline app and query it from any step:

var app = AppBuilder.Create()
    .WithHoloDb(db)
    .Build();

Running in the browser

HoloDb is pure managed .NET, so it compiles to WebAssembly and runs client-side with no server. Open the in-memory engine, load data, and query it — nothing leaves the device. The Analyst tool on this site is exactly that: a real HoloDb, in your browser tab, profiling and querying whatever you paste in.

Networked (client/server)

The same engine runs as a server. Pull the image and run it, with a data volume for durability and a token for auth:

docker run -p 5432:5432 -v holodb-data:/data \
  ghcr.io/evaluatedapplications/holodb:latest --token s3cret

Then talk to it from .NET with the client package — connect over TLS with a token and run the same SQL and bulk-load surface as the in-process engine, so moving from embedded to networked changes only how you get the handle:

dotnet add package EvaluatedApplications.HoloDb.Client

Current limits

Kept here so nothing surprises you in production:

← Back to HoloDb  ·  Try the Analyst →