Bit.BlazeDb.Bcrdt 11.0.0-pre-18

This is a prerelease version of Bit.BlazeDb.Bcrdt.
dotnet add package Bit.BlazeDb.Bcrdt --version 11.0.0-pre-18
                    
NuGet\Install-Package Bit.BlazeDb.Bcrdt -Version 11.0.0-pre-18
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Bit.BlazeDb.Bcrdt" Version="11.0.0-pre-18" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Bit.BlazeDb.Bcrdt" Version="11.0.0-pre-18" />
                    
Directory.Packages.props
<PackageReference Include="Bit.BlazeDb.Bcrdt" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Bit.BlazeDb.Bcrdt --version 11.0.0-pre-18
                    
#r "nuget: Bit.BlazeDb.Bcrdt, 11.0.0-pre-18"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Bit.BlazeDb.Bcrdt@11.0.0-pre-18
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Bit.BlazeDb.Bcrdt&version=11.0.0-pre-18&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Bit.BlazeDb.Bcrdt&version=11.0.0-pre-18&prerelease
                    
Install as a Cake Tool

Bit.BlazeDb

A memory-first, SQL-free embedded database engine for .NET, designed for Blazor WebAssembly running client-side in the browser.

[BlazeDbTable("todos")]
public partial class TodoItem
{
    [BlazeDbKey] public Guid Id { get; set; }
    public string Title { get; set; } = "";
    [BlazeDbIndex] public bool Done { get; set; }
}

await using var db = await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions
{
    Storage = await BlazeDbOpfsStorage.CreateAsync("mydb"),
}.AddTable(TodoItem.Table));

var todos = db.GetTable(TodoItem.Table);
todos.Insert(new TodoItem { Id = Guid.NewGuid(), Title = "hello" });
var open = todos.Lookup(TodoItem.Indexes.Done, false); // a hash index probe - no I/O, no SQL, no interop

Why

Existing client-side database options for Blazor WebAssembly (sqlite-wasm bridges, IndexedDB wrappers) pay heavy costs on every operation: JS interop marshalling, worker round-trips, and SQL parsing. BlazeDb eliminates all three:

  • All live data resides in managed memory as strongly-typed .NET objects. Reads are plain method calls into hash/ordered indexes - no I/O, no interop, no query language.
  • Durability is a background concern: commits apply to memory synchronously and are appended to a write-ahead log (WAL) that is flushed asynchronously to OPFS (Origin Private File System). Periodic snapshots compact the log.
  • No SQL: the engine exposes low-level, typed query primitives (Get, Range, Scan, BlazeDbQuery). The EF Core provider translates LINQ directly into those primitives - there is no intermediate query language to generate or parse.
  • AOT/trimming friendly: serialization and table metadata are produced by a Roslyn source generator - zero runtime reflection.
  • Built for the browser's constraints: cooperative yielding so long scans do not freeze the UI, storage-quota awareness instead of surprise write failures, encryption through WebCrypto, and a single-writer model with read-only replica tabs.
  • Reactive: every table reports the net change of each commit, and a live query re-runs only when a commit concerns it - so a component re-renders when its data changed, and only then.

Install

dotnet add package Bit.BlazeDb                     # the engine, the source generator and the browser backends
dotnet add package Bit.BlazeDb.EntityFrameworkCore # UseBlazeDb()

Installing Bit.BlazeDb is enough: there is no separate generator or browser package to add. It carries the source generator that turns a [BlazeDbTable] class into a table - it emits the Table descriptor and the Indexes class the rest of this guide uses - as an analyzer, so the generator adds nothing to the application's output. Reference the package directly from every project that declares table types: analyzers do not flow transitively, so a project that gets the engine only through another project, or only through Bit.BlazeDb.EntityFrameworkCore, compiles the attributes but generates nothing, and TodoItem.Table then does not exist.

The browser backends (OPFS, IndexedDB, WebCrypto, tabs) ship in the same package, under the Bit.BlazeDb.Browser namespace. They are marked [SupportedOSPlatform("browser")] - the rest of the engine is not - so on the server, the desktop or MAUI they are simply left unused.

Add the namespaces you use to _Imports.razor or a global using:

global using Bit.BlazeDb;
global using Bit.BlazeDb.Browser;   // the browser backends
global using Bit.BlazeDb.Storage;   // IBlazeDbStorage, the file and in-memory backends, encryption
global using Bit.BlazeDb.Querying;  // BlazeDbQuery, BlazeDbBound

Requirements

  • .NET 10 or .NET 11. Bit.BlazeDb.EntityFrameworkCore targets EF Core 10 and 11 respectively.
  • Blazor WebAssembly - standalone, or a Blazor Web App's .Client project - for the browser backends. The engine itself has no browser dependency: it runs in any .NET process, with the file or in-memory backend.

Packages

Package Purpose
Bit.BlazeDb The engine: tables, indexes, transactions, WAL/snapshot persistence, the storage abstraction with in-memory, file and encrypted backends. It also carries the Roslyn incremental source generator (binary serializers, key extractors and typed index definitions for [BlazeDbTable] types) as an analyzer, and - in Bit.BlazeDb.Browser, browser-only - the OPFS and IndexedDB storage backends ([JSImport] ES modules), Web Locks single-writer election, a WebCrypto cipher and cross-tab sync.
Bit.BlazeDb.EntityFrameworkCore The EF Core provider: UseBlazeDb, change tracking over live rows, LINQ translated to query plans.
Bit.BlazeDb.Blex Persists Bit.Blex stores in a BlazeDb database instead of localStorage (see Bit Platform integrations).
Bit.BlazeDb.Bcrdt Keeps Bit.Bcrdt collaborative documents in a BlazeDb database (see Bit Platform integrations).

Everything public is prefixed BlazeDb (BlazeDbDatabase, BlazeDbTable<TKey, TRow>, BlazeDbOpfsStorage...) and lives under the Bit.BlazeDb namespace and its children.

The demo

The demo site, blazedb.bitplatform.dev, is an interactive tour of the engine with no external dependencies - no CSS framework, no icon font, no JavaScript library, no web fonts. Every page drives the real engine in your browser rather than describing it:

Page What it demonstrates
/ The landing page: what the engine is, a live insert into the tab's database, and how it compares with SQLite.
/getting-started Install, declare a table, open a database once per tab, and how a read and a write travel.
/hosting Every hosting model - WebAssembly, a Blazor Web App and prerendering, Blazor Server, MAUI, the desktop - and the threading model.
/todos A working CRUD app: index-backed filters and facets, sorting, paging, optimistic edits, bulk deletes and persisted preferences.
/schema Attributes, the generated descriptor inspected at runtime, supported types, records and index-only properties, every diagnostic with its fix.
/writes Every write: the Try forms, optimistic concurrency, atomic bulk writes, unique indexes, rows changed in place and VerifyIntegrity.
/indexes Hash lookups, facets, ordered ranges with inclusive and exclusive bounds, compound and unique indexes, prefix scans and keyset paging.
/queries A query composer that shows the generated C#, Explain() and the results, and a large result streamed with ExecuteAsync.
/efcore The EF Core provider: every UseBlazeDb, live LINQ, SaveChanges, concurrency, transactions, a persistent store, and what the translator absorbs.
/transactions Commit, rollback, dispose-without-commit, savepoints, RunInTransaction and crash-recovery proof.
/changes Changed events and a live query over a private database: what each kind of commit reports, what is never reported, and handlers that write or throw.
/durability WAL, snapshots, checkpoints, what survives, and simulated crashes including torn tails and corruption.
/backups ExportAsync and ImportAsync - a download of the tab's own database included - UserVersion, migrations, and tables the model does not register.
/storage The IBlazeDbStorage contract and the backends that ship, a live trace of engine I/O, OPFS and Web Locks, and the storage quota run into live.
/tabs A writer tab and read-only replicas following its checkpoints through BlazeDbTabSync, and the OPFS-or-IndexedDB choice.
/encryption BlazeDbEncryptedStorage with WebCrypto: the ciphertext next to the plaintext, a wrong key, tampering, a torn frame and key rotation.
/serialization Build a row and inspect the exact bytes, field by field, with a hex dump.
/errors Every exception the engine raises, triggered live against throwaway databases.
/bench Read, write and encoding benchmarks measured in your own browser, next to the desktop BenchmarkDotNet figures.
/roadmap What ships - each item linked to the page that drives it - what never will, and how the repository is laid out.
/mcp-server The MCP server this site hosts for AI agents.

Samples is the hosting harness rather than the docs: one shared Core project of pages (a todo list kept current by a live query, BlazeDbQuery with Explain(), change notifications, and flushes, checkpoints and exports), hosted unchanged under Server, Wasm, Auto and Maui, a Blazor Hybrid app for Android, iOS, macOS and Windows. The pages are the same everywhere; where the database lives is not, and each host is a minimal example of the wiring its hosting model needs: files on the server shared by every circuit behind one lock (Server), OPFS in the browser tab with a placeholder wherever the page renders on the server (Wasm and Auto), and files in the app's data directory (Maui). Lazy stands apart: a Blazor Web App of its own whose browser half downloads Bit.BlazeDb.wasm only when its todos page, the part with the database, opens (see lazy loading).

MCP server

The demo hosts a Model Context Protocol server at https://blazedb.bitplatform.dev/mcp, so an AI agent writing database code reads the library itself - its API, this guide, the source generator and the demo's working pages - instead of guessing at an API it half remembers.

{
  "mcpServers": {
    "blazedb": { "type": "http", "url": "https://blazedb.bitplatform.dev/mcp" }
  }
}

Start with SearchBlazeDb, which searches this guide, every public type and member, the generator's diagnostics, the setup guides and the demo's pages at once, and points each hit at the exact follow-up call. GetBlazeDbSetupGuide gives the wiring for one hosting model, GetBlazeDbGuideSection one section of this guide, GetBlazeDbApi one public type with its members, real defaults and documentation, GetBlazeDbDiagnostics one BLZ diagnostic with its cause and fix, and GetBlazeDbSourceFile one of the demo's working files; each returns its index when called without a key. InspectBlazeDbTable runs the real source generator over a [BlazeDbTable] class and reports its diagnostics, the field number and wire type of every property, the key, the indexes and the members it generates. Every tool is read-only.

Quick start

[BlazeDbTable("todos")]
[BlazeDbCompoundIndex("CategoryDone", nameof(Category), nameof(Done))]
public partial class TodoItem
{
    [BlazeDbKey] public Guid Id { get; set; }
    [BlazeDbIndex(Unique = true)] public string Slug { get; set; } = "";
    public string Title { get; set; } = "";
    public string? Category { get; set; }
    [BlazeDbIndex] public bool Done { get; set; }
    [BlazeDbOrderedIndex] public DateTime CreatedAt { get; set; }
}

var db = await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions
{
    Storage = await BlazeDbOpfsStorage.CreateAsync("mydb"), // or BlazeDbFileStorage / BlazeDbInMemoryStorage / null
}.AddTable(TodoItem.Table));

var todos = db.GetTable(TodoItem.Table);

var id = Guid.NewGuid();
todos.Insert(new TodoItem { Id = id, Slug = "hello", Title = "hello", CreatedAt = DateTime.UtcNow });
var one = todos.Get(id);                                  // O(1), no I/O
var open = todos.Lookup(TodoItem.Indexes.Done, false);    // hash index
var latest = todos.OrderBy(TodoItem.Indexes.CreatedAt, descending: true);
var range = todos.Range(TodoItem.Indexes.CreatedAt, from, to);
var both = todos.Lookup(TodoItem.Indexes.CategoryDone, ("work", false)); // one compound lookup

using (var txn = db.BeginTransaction())
{
    todos.Update(new TodoItem { Id = id, Slug = "hello", Title = "hello", Done = true, CreatedAt = one!.CreatedAt });
    txn.Commit();
}

await db.FlushAsync(); // force durability now (otherwise flushed in the background)

Rows are live objects

Rows are live objects - a read hands back the instance the table holds. Update therefore takes a new instance (as above): the table retracts the old index entries through the values of the row it currently holds, so mutating that instance's indexed properties and passing it back is refused rather than left to strand index entries. To write a row that was changed in place, hand the table the values it had: todos.UpdateInPlace(row, previousValues) / todos.DeleteInPlace(key, previousValues) - which is what the EF Core provider does with the change tracker's originals. previousValues must carry every indexed property as the table last saw it; if the row has been written since those values were taken, the call throws BlazeDbStaleRowException instead of leaving the row indexed twice.

Null values

Null values are indexed like any other: todos.Lookup(TodoItem.Indexes.Category, null) returns the uncategorized rows, ordering by an index puts them first (as OrderBy does), and a bounded Range excludes them. A single-property unique index still allows any number of nulls; a compound one compares the whole tuple, so null members are not exempt there.

Files on the desktop

On desktop, BlazeDbFileStorage takes an exclusive lock on its directory for as long as it lives - dispose it when the database is closed - and BlazeDbFileStorage.OpenReadOnly(dir) opens the same directory for a follower that only reads. BlazeDbDatabase.DeleteAsync(storage) removes a database from any backend.

Threading model

BlazeDb is built for Blazor WebAssembly, which has one thread, and its guarantees are those of a single logical writer with no concurrent readers. Changed handlers and live queries run on the thread that committed - each thread delivers its own commits - so a Blazor component hands its re-render to the renderer with InvokeAsync. Writes are serialized by a lock so the background flusher can snapshot consistently, but reads - Get, Scan, index lookups, every LINQ query through EF Core - take no lock at all, because on the platform the engine is designed for there is nothing to lock against. A read that overlaps a write on another thread is a data race on the underlying dictionaries and may throw or return garbage.

That matters as soon as the engine leaves the browser. On ASP.NET Core or Blazor Server, UseBlazeDb shares one engine between every context of a type, and requests run on different threads; that is not a supported configuration unless the application serializes all access itself (one request at a time, or a lock around every query and save). The desktop file backend is for tools, tests and single-threaded hosts, not for a multi-threaded server.

Opening with part of the model

A database may hold tables the current options do not register - a context type that owns some of a shared store's tables, or an application that has dropped a table type. Such tables are still opened: their rows are carried as raw bytes through recovery and written back out by every checkpoint, so nothing is lost, and db.UnregisteredTables names them. They cannot be queried until a descriptor for them is registered, which the EF Core provider does automatically when a context that knows the table shares the engine. Snapshots written by versions before this behavior (format 1) still open, but only with every table registered.

Streaming results

Streaming a large result set without blocking the browser's only thread:

await foreach (var todo in BlazeDbQuery<Guid, TodoItem>.From(todos)
    .UseIndex(TodoItem.Indexes.CreatedAt, from, to)
    .Where(t => !t.Done)
    .ExecuteAsync(batchSize: 256))
{
    Render(todo);
}

Between batches the stream hands control back, and other code - another event handler - may run. If that code writes to the table, the rest of the stream would describe neither the table as it was nor as it is, so the stream throws InvalidOperationException naming the table instead. Writes made while the loop body runs - by the body itself, or by other code while the body awaits - are the loop's business, as in a foreach over Execute(): the stream carries on past those the source tolerates (replacing a row it has already reached, say) and reports the rest with the same exception. Run the query again, or materialize it first, when other code may write meanwhile.

Writing rows

Insert, Update, Upsert and Delete are the basic writes. The Try forms report instead of throwing, and the bulk forms are atomic:

Call What it does
Insert(row) / TryInsert(row) Adds a row; a duplicate key throws BlazeDbDuplicateKeyException / returns false.
Update(row) / TryUpdate(row) Replaces the row with that key; a missing one throws KeyNotFoundException / returns false.
Upsert(row) Inserts or replaces.
Delete(key) Removes a row; false when there was none.
TryUpdate(row, comparisonValue) / TryDelete(key, comparisonValue) Writes only while the table still holds comparisonValue for the key - optimistic concurrency.
InsertMany, UpsertMany, DeleteMany(keys), DeleteWhere(predicate), DeleteAll() Atomic: every row or none, one log record. The input is read in full first, so it may be a query over the same table.

A unique index is enforced by every one of them - TryInsert decides on the key alone, so a duplicate unique value still throws BlazeDbUniqueConstraintViolationException. Null rows and null keys are refused with the table named.

Optimistic concurrency fits the way rows are replaced rather than mutated: read a row, build its replacement, and write it with the row you read as the comparison. If anything replaced or deleted the row meanwhile, nothing is written and the call returns false. Rows compare with EqualityComparer<T>.Default

  • by reference for a class, so the check is exact because every write installs a new instance; by value for a record.
var read = todos.Get(id)!;
var edited = new TodoItem { Id = id, Slug = read.Slug, Title = newTitle, Category = read.Category, Done = read.Done, CreatedAt = read.CreatedAt };
if (!todos.TryUpdate(edited, comparisonValue: read))
{
    // Someone changed it since the form opened: re-read and ask the user.
}

DeleteMany reads its keys in full before it deletes, so they can come straight from a query over the same table - a retention sweep is one line:

todos.DeleteMany(todos.Range(TodoItem.Indexes.CreatedAt, BlazeDbBound<DateTime>.Unbounded, cutoff).Select(t => t.Id));

Transactions

A transaction makes a group of writes atomic: they are applied to memory as they are made, reverted - rows and index entries alike - if it rolls back, and journaled as one log record when it commits. One is open at a time, and while it is, every write on the database joins it (ambient). Two ways to use one:

// The unit of work as a block: commits as one record, or - when it throws - leaves nothing behind.
db.RunInTransaction(() =>
{
    orders.Insert(order);
    stock.Update(lowered);
});

// Or explicitly, with savepoints to undo part of it.
using (var txn = db.BeginTransaction())
{
    todos.Insert(first);
    var savepoint = txn.CreateSavepoint();
    todos.Insert(second);
    txn.RollbackTo(savepoint);   // second is gone, first stays
    txn.Commit();                // disposing without Commit rolls back
}

RunInTransaction composes: inside an open transaction it joins it behind a savepoint, so a failure takes back only its own writes - even after the unit rolled the transaction back past its own start - and the outer transaction carries on. A second BeginTransaction while one is open throws instead - compose with RunInTransaction or a savepoint. Rolling back to a savepoint discards the ones taken after it.

What the engine guarantees, and what it does not:

  • Atomic: a transaction commits whole or not at all - in memory, in the log, and after a crash. A commit the log cannot record (a row that will not serialize) is undone and throws.
  • Consistent: duplicate keys and unique-index violations are refused before anything changes, and index maintenance either completes or is unwound, even when a hand-written index definition throws. If a rollback itself cannot undo a write (only such an index can make it fail), a database with storage refuses further writes, checkpoints and exports until it is reopened, rather than let one of them persist a write the log never recorded; the exception that made the rollback happen is still the one you see. A database without storage has nothing to reopen from, so it carries on, and VerifyIntegrity() reports the write it could not take back. Either way, a transaction that part of could not be rolled back (a failed RunInTransaction unit or EF Core save inside it, or a savepoint) refuses to commit and rolls back.
  • No isolation from the open transaction: there is one writer, and its writes are visible to every reader as soon as they are made, before the commit. There is nothing else to isolate from.
  • Durable when flushed: see Persistence.

There is deliberately no asynchronous RunInTransaction, and a transaction should not span an await: while it waits, other code runs - another event handler on WebAssembly - and its writes join the transaction, to commit or roll back with it. Disposing the database rolls back a transaction left open.

Querying

Every read is a method call on live objects, and which one decides the cost:

Read Cost
Get(key), TryGet, Contains O(1)
Lookup(hashIndex, value), CountBy(hashIndex, value) O(1) to find, then the matching rows
CountBy(hashIndex) every distinct value with its row count, one step per value - filter facets
OrderBy(orderedIndex, descending) O(log n) to start, then rows in order
Range(orderedIndex, from, to, descending) O(log n) to start; bounds inclusive, BlazeDbBound<T>.Exclusive(v) or Unbounded
StartsWith(orderedStringIndex, prefix) a range scan over an ordinal string index
Scan() every row

Aggregates, grouping, projections and joins are LINQ to Objects over those reads - the rows are already objects, so there is nothing to materialize. Indexes return their rows lazily; materialize with ToList() before writing to the same table in a loop over an ordered index.

Keyset paging uses an exclusive bound: the next page starts strictly after the last row of this one. When the sort value can repeat, page over a compound ordered index that ends in something unique:

[BlazeDbCompoundOrderedIndex("CreatedId", nameof(CreatedAt), nameof(Id))]
// ...
var page = todos.Range(TodoItem.Indexes.CreatedId, BlazeDbBound<(DateTime, Guid)>.Exclusive((last.CreatedAt, last.Id)),
                       BlazeDbBound<(DateTime, Guid)>.Unbounded).Take(20).ToList();

BlazeDbQuery<TKey, TRow> composes one index source with filters, ordering (OrderBy, ThenBy) and paging, and Explain() says how it will run - which index, what is filtered and sorted in memory - without the values it compares against:

var query = BlazeDbQuery<Guid, TodoItem>.From(todos)
    .UseIndex(TodoItem.Indexes.CreatedAt, from, BlazeDbBound<DateTime>.Unbounded, descending: true)
    .Where(t => !t.Done)
    .Take(20);
Console.WriteLine(query.Explain());
// source: ordered index 'CreatedAt' range [from, unbounded) descending
// filter: 1 predicate, evaluated on each source row
// order: index order, no sort
// take: 20

Reacting to changes

Every table raises Changed after each commit that changed it, and the database raises Changed after each commit with the changes to every table:

todos.Changed += (_, e) =>
{
    foreach (var change in e.Changes)
    {
        // change.Kind (Inserted, Updated, Deleted), change.Key, change.OldRow, change.NewRow
    }
};
db.Changed += (_, e) => outbox.Enqueue(e.Changes);   // every table, rows as objects - audit logs, sync outboxes
  • Once per commit, net: a transaction that updates a row three times reports one update, from the row before the commit to the row after it; a row inserted and deleted again inside one commit is not reported at all.
  • Committed states only: nothing is reported before the commit, for a rollback, a savepoint rolled back to, or a commit the log refused.
  • After the lock: handlers run synchronously on the thread that committed, once the commit has completed and the write lock is released. A handler may read and write; a write it makes is reported after the current notification has reached every subscriber, so a thread's notifications arrive in the order it committed. On a multi-threaded host each thread delivers its own commits, so handlers for commits made on different threads may run at the same time and in either order - one thread never waits for another's handlers. A database-wide subscriber that needs one ordered stream (an outbox) has to serialize itself there, as the host has to serialize access anyway (see Threading model).
  • Isolated: every subscriber hears of a commit even if one throws; the exceptions then surface from the write that committed, as a BlazeDbChangeHandlerException - the write did commit, so do not retry it. Through EF Core, SaveChanges completes first (the tracker accepts what was saved) and then throws it.
  • Free when unused: a commit collects changes only for tables somebody subscribed to.
  • A read-only replica raises Changed with IsReset after ReloadAsync: the whole table was replaced.

A live query keeps a result current. It re-runs after each commit to its table - or, given a relevance test, only after commits that changed a row the test accepts, before or after the change:

@implements IDisposable

@code {
    private BlazeDbLiveQuery<List<TodoItem>> _open = null!;

    protected override void OnInitialized()
    {
        _open = Todos.Watch(t => t.Lookup(TodoItem.Indexes.Done, false).ToList(), relevantTo: t => !t.Done);
        _open.Changed += (_, _) => InvokeAsync(StateHasChanged);
    }

    public void Dispose() => _open.Dispose();
}

_open.Result is the current list. Return a materialized result from the query (ToList(), a count, a row): a lazy sequence would be enumerated later, against whatever the table holds by then.

Modeling data

A table is a partial class, record or struct with one [BlazeDbKey]. Properties with a setter (or init) are stored, in the binary format described under Schema evolution:

Kind Types
Numbers bool, byte, sbyte, short, ushort, int, uint, long, ulong, Int128, UInt128, float, double, Half, decimal, char, enums
Text and bytes string, byte[]
Time DateTime (kind kept), DateTimeOffset, TimeSpan, DateOnly, TimeOnly
Identity Guid
Collections List<T>, T[], IList<T>, ICollection<T>, IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T> (read back as a List<T>), HashSet<T>, ISet<T>, IReadOnlySet<T> (read back as a HashSet<T>) of a non-nullable scalar

Every scalar may be nullable. A key must be a non-nullable scalar other than byte[].

Rows are built through the parameterless constructor when the type has one. A type without one - a positional record, or a class that takes its get-only properties in its constructor - is built through the constructor whose parameters all name its properties, and whatever that constructor does not take is set afterwards:

[BlazeDbTable("contacts")]
public partial record Contact([property: BlazeDbKey] int Id, string Name, [property: BlazeDbOrderedIndex] DateOnly Birthday)
{
    public string? Nickname { get; init; }

    [BlazeDbIndex] public string Initial => Name.Length == 0 ? "" : Name[..1];   // computed: indexed, not stored
}

A computed get-only property (=> ...) with an index attribute - or named by a compound index - is index-only: its value is computed from the stored ones, the index keys on it, and the row bytes leave it out. A get-only auto-property holds a value of its own that a reload could not restore, so indexing one the constructor does not take is refused (BLZ017), and so is a required member BlazeDb does not store (BLZ018). That is how a derived value (a domain from an e-mail address, a normalized search key, a value from inside a collection) becomes something an index can find. The usual rule for live rows applies: change the stored property through a new row, and the index moves with it.

Nested objects are not stored: model a one-to-many relationship as a second table keyed by the parent's key (with a hash index on it), or keep a small value object in a string or byte[] property you encode yourself.

Backups, versions and migrations

ExportAsync writes the whole database - every table, unregistered ones included, and the user version - to a stream as one checksummed file; ImportAsync turns that file into a database on any backend:

await using var file = File.Create("backup.blazedb");
await db.ExportAsync(file);                                    // a consistent state, captured at once

await using var source = File.OpenRead("backup.blazedb");
using var restored = new BlazeDbFileStorage(restoreDirectory);
await BlazeDbDatabase.ImportAsync(source, restored);           // then OpenAsync over it as usual

An import verifies the checksum before it writes anything, refuses a file that was truncated or altered (BlazeDbCorruptDatabaseException), and refuses a destination that already holds a database. Export refuses while a transaction is open, since its writes are not committed, and - like a checkpoint - after a rollback that could not undo a write. The file is not encrypted.

UserVersion is a number stored with the data for the application to record which shape the data is in - 0 until something sets it, like SQLite's user_version. Migrations registered on the options bring a database forward when it opens:

var options = new BlazeDbDatabaseOptions { Storage = storage }
    .AddTable(TodoItem.Table)
    .AddMigration(1, db =>
    {
        // Version 1 gave every todo a slug; rows written before it have none.
        var todos = db.GetTable(TodoItem.Table);
        todos.UpsertMany(todos.Scan().Where(t => t.Slug == "").Select(t => new TodoItem
        {
            Id = t.Id, Slug = $"todo-{t.Id:N}", Title = t.Title, Category = t.Category, Done = t.Done, CreatedAt = t.CreatedAt,
        }).ToList());
    })
    .AddMigration(2, db => { /* ... */ });

Each migration whose version is above the database's runs once, lowest first, inside a transaction that also moves UserVersion to its version - so it either completes and never runs again, or fails the open and leaves the database at the version before it, to be retried. A read-only replica does not run them. Field-level changes need no migration (Schema evolution); a migration is for changing the data itself.

Checking integrity

db.VerifyIntegrity() compares every row with every index entry and returns what disagrees - an empty list on a healthy database. The engine keeps them in step itself; the one thing it cannot see is a row object changed in place after it was written (its key, or a property an index covers), which leaves lookups returning rows that no longer match them. It also reports a rollback that could not undo a write - which on a database without storage nothing else reports once the rollback has thrown. Run it in tests, or from a support tool, to find such code:

foreach (var issue in db.VerifyIntegrity())
{
    Console.WriteLine($"{issue.Table} / {issue.Index ?? "primary key"}: {issue.Description}");
}

Persistence guarantees

Configuration What survives
Storage = null Nothing: the database is memory only, gone with the instance.
A storage backend Every commit flushed to it. Commits reach storage on the background flusher within FlushInterval (100 ms by default), or at once with await db.FlushAsync(); a crash loses what was committed since the last flush, never part of a commit.
After a flush fails Committed data stays in memory and the flusher retries; db.LastBackgroundError reports the standing failure.

A flush counts as done only once the backend says the bytes are durable: BlazeDbFileStorage flushes each file to disk and, on Linux, Android and macOS, the directory as well whenever a file is created or replaced, so a new name survives a power loss along with its contents; BlazeDbIndexedDbStorage commits with strict durability, so a transaction completes only after the browser has flushed it; OPFS writes are atomic replacements through the browser's swap file. A process or tab that ends part-way through a checkpoint can leave the replaced generation's files behind; the writer removes them the next time it opens, on every backend that can list its files (all the built-in ones).

Recovery replays the log up to its torn tail - a record a crash cut short, with nothing intact after it - and cuts the tail off, so the database comes back as of a commit boundary. A record that appears again, as a retried append can leave it, is applied once. Damage anywhere else - a record that fails its checksum with intact ones after it, or a commit missing from the sequence - is not something a crash leaves, so the open throws BlazeDbCorruptDatabaseException and leaves the files as they are rather than cutting away the commits behind the damage. Browser storage is the origin's to keep or evict: it can be cleared by the user, evicted under storage pressure, or unavailable in a private window - ask for persistence with BlazeDbOpfsStorage.RequestPersistenceAsync(), and keep exports of what must not be lost.

Hosting models

Where a database can live follows from two facts: the browser backends need the WebAssembly runtime, and the engine assumes one logical writer with no concurrent readers. Everything else - the tables, the queries, the EF Core provider - is the same wherever it runs.

Blazor WebAssembly

The model the engine is designed for. The database lives in the tab, in managed memory, and persists to OPFS through BlazeDbOpfsStorage (or to IndexedDB where OPFS cannot be written - see Multiple tabs). Open it once per tab and share it: register a singleton service that opens the database on first use, the way the demo's DemoDbService does, rather than opening one per component. WebAssembly has one thread, so the threading model holds by construction.

public sealed class AppDatabase : IAsyncDisposable
{
    private Task<BlazeDbDatabase>? _opening;
    private BlazeDbOpfsStorage? _storage;

    public Task<BlazeDbDatabase> GetAsync() => _opening ??= OpenAsync();

    private async Task<BlazeDbDatabase> OpenAsync()
    {
        _storage = await BlazeDbOpfsStorage.CreateAsync("app"); // takes this tab's writer lock
        return await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions { Storage = _storage }.AddTable(TodoItem.Table));
    }

    public async ValueTask DisposeAsync()
    {
        if (_opening is not null) await (await _opening).DisposeAsync(); // flushes what is still pending
        _storage?.Dispose();                                             // and only then releases the lock
    }
}

// Program.cs
builder.Services.AddSingleton<AppDatabase>();

The database does not own its storage: dispose the storage after the database, which is what releases the writer lock for the next tab. CreateAsync throws BlazeDbDatabaseLockedException when another tab already holds it - open a read-only replica there instead, or fall back to BlazeDbInMemoryStorage the way the demo does.

There is no script tag to add: each browser backend imports its ES module the first time it is used, from _content/Bit.BlazeDb/ resolved against the document's <base href>. Every CreateAsync takes a moduleUrl for an app that serves the modules from somewhere else. An app that is not compiled ahead of time can download the engine only when a page that uses the database opens - see lazy loading.

Blazor Web App

The database still lives in the browser, so the code that opens it belongs in the .Client project and runs under InteractiveWebAssembly. What changes is prerendering: a prerendered component runs on the server first, where there is no OPFS, no IndexedDB and no Web Locks - the backends in Bit.BlazeDb.Browser are [SupportedOSPlatform("browser")] and throw outside the browser. Either turn prerendering off for the pages that use the database (@rendermode="new InteractiveWebAssemblyRenderMode(prerender: false)", which is what the demo does for the whole app, since every one of its pages opens the database), or keep it and open the database only once the component runs in the browser - in OnAfterRenderAsync, or behind OperatingSystem.IsBrowser() - rendering a placeholder until then. Under InteractiveAuto the same applies to the first visit, which renders on the server while the runtime downloads.

Blazor Server and ASP.NET Core

The engine runs in the server process, persisting through BlazeDbFileStorage. Every circuit and every request runs on its own thread, which the engine does not guard against (see Threading model): serialize all access to a shared database yourself, or give each user a database of their own in a directory of their own. For a multi-user server store, a database server is the right tool; BlazeDb is the local store of one user's application.

Blazor Hybrid and desktop

MAUI, WPF and Windows Forms apps run .NET in-process, so the browser backends do not apply even though the UI is rendered in a WebView. Persist through BlazeDbFileStorage in the app's data directory (FileSystem.AppDataDirectory on MAUI), opened once for the app's lifetime and disposed when it closes. Components render on the UI thread, which serializes their access; marshal work from background threads back to it. Console tools and tests use BlazeDbFileStorage or BlazeDbInMemoryStorage, which also makes crash and corruption scenarios reproducible.

Lazy loading on WebAssembly

BlazeDb registers no services and each browser backend imports its module the first time it is used, so a WebAssembly app can fetch the engine only when someone opens a part of the app that uses the database. Mark the assembly as lazy loaded in the project that runs in the browser, and load it before the first page that uses it renders:

<ItemGroup>
  <BlazorWebAssemblyLazyLoad Include="Bit.BlazeDb.wasm" />
</ItemGroup>
@* App.razor - in a Blazor Web App, the client project's Routes.razor: the router waits for OnNavigateAsync
   before it renders the page. typeof(Program) is the project the router's file is in, in either kind of app. *@
@using Microsoft.AspNetCore.Components.WebAssembly.Services
@inject LazyAssemblyLoader LazyAssemblyLoader

<Router AppAssembly="typeof(Program).Assembly" OnNavigateAsync="OnNavigateAsync">
    ...
</Router>

@code {
    private async Task OnNavigateAsync(NavigationContext context)
    {
        // The pages that use the database: fetch BlazeDb before they render. A second call loads nothing again.
        // Routes match regardless of case, so /Todos/2 is one of them too, and /todos-archive is not.
        var path = context.Path.Split('?', '#')[0]; // the path keeps the query string and the fragment
        if (path.Equals("todos", StringComparison.OrdinalIgnoreCase) || path.StartsWith("todos/", StringComparison.OrdinalIgnoreCase))
            await LazyAssemblyLoader.LoadAssembliesAsync(["Bit.BlazeDb.wasm"]);
    }
}

The table types stay where they are: the code the generator adds keeps the engine's types out of a [BlazeDbTable] type's fields, so code that runs before the assembly is there - a layout every page shares - can create one and read its properties; only Table and Indexes need the assembly. That is why Table and the members of Indexes are static properties. They were static readonly fields in earlier versions, so an assembly compiled against one of those that reads them - a library with queries over another assembly's table types - has to be rebuilt, or it fails with a MissingFieldException; reflection finds them with GetProperty, not GetField.

The service that opens the database once per tab is registered in Program.cs, before the assembly is there, so its own fields must not be of the engine's types either. Keep them in a nested class that is created when the first page asks for the database:

public sealed class AppDatabase : IAsyncDisposable
{
    private Opened? _opened;   // not a Task<BlazeDbDatabase>: Program.cs names AppDatabase before Bit.BlazeDb loads

    public Task<BlazeDbDatabase> GetAsync()
    {
        if (_opened is null || _opened.Failed) _opened = new Opened(); // a failed open is tried again
        return _opened.Database;
    }

    public async ValueTask DisposeAsync()
    {
        if (_opened is { Failed: false }) await _opened.DisposeAsync(); // a failed open has released the lock already
    }

    // The engine's types live here, laid out only once a page asks for the database.
    private sealed class Opened : IAsyncDisposable
    {
        private BlazeDbOpfsStorage? _storage;

        public Opened() => Database = OpenAsync();

        public Task<BlazeDbDatabase> Database { get; }

        public bool Failed => Database.IsFaulted || Database.IsCanceled;

        private async Task<BlazeDbDatabase> OpenAsync()
        {
            _storage = await BlazeDbOpfsStorage.CreateAsync("app"); // takes this tab's writer lock
            try
            {
                return await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions { Storage = _storage }.AddTable(TodoItem.Table));
            }
            catch
            {
                _storage.Dispose();                                 // releases the lock, for the next attempt or tab
                throw;
            }
        }

        public async ValueTask DisposeAsync()
        {
            try
            {
                await (await Database).DisposeAsync();              // flushes what is still pending
            }
            finally
            {
                _storage?.Dispose();                                // and only then releases the lock
            }
        }
    }
}

// Program.cs - as before
builder.Services.AddSingleton<AppDatabase>();
  • Only code that runs after the assembly has loaded may use the engine - the pages it is loaded for and a layout only they use, not Program.cs, App.razor, Routes.razor or a layout every page shares. A check that skips the database on the other pages is not enough: the interpreter compiles a whole method the first time it is called - an async method's whole state machine - and loads every type it names, in the branch that does not run too. The pages themselves may keep the engine's types in fields, as the live query under Reacting to changes does.
  • A type that code running before then names may not have a field of one of the engine's types. Registering it - AddSingleton<AppDatabase>(), AddSingleton(typeof(AppDatabase)) and a factory alike - makes the runtime lay it out, and on WebAssembly a field whose type cannot be loaded yet fails the app as it starts. The first AppDatabase under Blazor WebAssembly, with its Task<BlazeDbDatabase> _opening, fails with Could not load type of field 'AppDatabase:_opening' ... Could not load file or assembly 'Bit.BlazeDb'. A type's methods may take and return the engine's types; only fields count.
  • No public type in an assembly the router reads when the app starts - the app's own assembly first of all - may derive from or implement one of the engine's: no public IBlazeDbStorage wrapper or IBlazeDbAeadCipher of your own. The router reads the public types of those assemblies at startup, and fails with a ReflectionTypeLoadException on such a type before Bit.BlazeDb is there. Make it internal, or put it in a library that is lazy loaded together with Bit.BlazeDb.
  • Serializing a table type - with System.Text.Json, reflection-based or source-generated - reads the BlazeDb attributes on its properties, and so needs the assembly. Give a call that runs before it has loaded, such as one the main layout makes, a type of its own.
  • A failed attempt is final: once the runtime has tried to load Bit.BlazeDb before it was there - even in code that caught the exception - it does not try again, and the pages that use the database fail after the assembly has loaded too.
  • With the EF Core provider, lazy load Bit.BlazeDb.EntityFrameworkCore.wasm as well, in the same call. AddDbContext<AppContext>(options => options.UseBlazeDb()) can stay in Program.cs: the lambda runs when the first context is created, by then in a page the assemblies were loaded for. A persistent store cannot be given its storage there, since creating one uses the engine: give the context the database the service above opened instead, new AppContext(new DbContextOptionsBuilder<AppContext>().UseBlazeDb(await Database.GetAsync()).Options). EF Core itself loads with the app, since the context derives from its DbContext.
  • In a Blazor Web App only the browser lazy loads. Give Routes the InteractiveWebAssembly render mode in App.razor, so the router - and its OnNavigateAsync - runs in the browser; with per-page render modes it renders statically on the server and never loads the assembly in the browser. Under InteractiveAuto the router runs in the browser only once the runtime has been downloaded: the first visit runs it in a server circuit, where OnNavigateAsync loads nothing in the browser and the pages render on the server - so they open the database only once they run in the browser, as under Blazor Web App. The server project loads BlazeDb with the rest of the app, and registers LazyAssemblyLoader, which Routes.razor injects whenever it renders there and which loads nothing outside the browser: builder.Services.AddScoped<LazyAssemblyLoader>().
  • Do not lazy load in an AOT build. AOT compiles every assembly into dotnet.native.wasm, so there is nothing left to download later, and lazy loading an AOT-compiled assembly can fail at startup.

The lazy loading sample does all of this in a Blazor Web App - its project file leaves the mark out of AOT builds, and sets a switch next to it that tells its router whether there is an assembly to load - and the end-to-end suite runs its published copy in a browser, trimmed and AOT-compiled: AppDatabase is registered in the client's Program.cs and the layout every page shares creates a table type, yet the home page never downloads Bit.BlazeDb.wasm; the todos page - opened from the home page or by a deep link the server prerenders - downloads it once, opens the database through AppDatabase (on IndexedDB where OPFS cannot be written, as under Without OPFS), and finds the rows it wrote again after a reload.

Encryption at rest

BlazeDbEncryptedStorage wraps any backend and seals each write into its own AES-GCM frame, so WAL appends stay appends. In the browser, use the WebCrypto cipher - the managed AesGcm throws inside the browser sandbox:

var key = BlazeDbEncryptedStorage.DeriveKey(passphrase, salt);   // PBKDF2, works everywhere
var cipher = await BlazeDbWebCryptoCipher.CreateAsync(key);      // BlazeDbAesGcmCipher on desktop
var storage = new BlazeDbEncryptedStorage(await BlazeDbOpfsStorage.CreateAsync("mydb"), cipher, ownsCipher: true);

What this does and does not protect: every frame is sealed with a fresh random 96-bit nonce and the file name as associated data, so contents cannot be read or altered without the key and a frame cannot be moved between files. Each database names its snapshots and logs with a random id of its own (wal-{id}-N.blz), so that also keeps apart databases sealed with the same key - one deleted and created again on the same backend, say: a frame of one does not open in the files of another. A database created before file names carried an id takes one at its next checkpoint. Within the log, a frame dropped from the middle is reported as corruption and a frame copied in again is skipped, because every commit carries the next sequence number. It does not prevent rollback - someone with access to the files can put back an older, internally consistent manifest, snapshot and log, a whole other database's among them, or cut frames off the end of the log, which reads the same as commits that never reached storage. There is no key rotation: to change the key, open the database, checkpoint, and write it out through a storage wrapped with the new cipher. Random nonces are safe for about 2^32 frames per key; every flush writes one and every checkpoint two, so at the default flush interval that is about a decade of continuous writing, and a long-lived database should rotate before then.

Storage quota

When the backend can report the origin's allowance, the engine checks it before flushing, compacts if space is short, and refuses deliberately rather than failing mid-append. The data stays intact in memory and the flush can be retried once space is freed:

var options = new BlazeDbDatabaseOptions
{
    Storage = storage,
    QuotaReserveBytes = 4 * 1024 * 1024,
    OnQuotaPressure = quota => ShowStorageWarning(quota.UsedFraction),
};

var quota = await db.GetStorageQuotaAsync();   // null when the backend cannot say
await BlazeDbOpfsStorage.RequestPersistenceAsync();   // ask not to be evicted

The estimate covers the whole origin - the Cache API, other databases and other tabs write there too - and some browsers do not give one, so a write can still meet the real limit. The browser backends report that refusal (the browser's QuotaExceededError) as BlazeDbStorageQuotaExceededException too, and the engine handles it the same way: it compacts, which on success has made the buffered commits durable in the new snapshot, and otherwise calls OnQuotaPressure and refuses the flush.

Wrapping a browser backend in BlazeDbEncryptedStorage keeps its quota awareness. When a background flush is refused (or fails for any other reason) the data stays committed in memory and the flusher keeps retrying; db.LastBackgroundError reports the standing failure so a UI can say durability is lagging, and clears again once a flush succeeds.

Multiple tabs

One tab holds the writer lock; the others open the same database read-only and reload when the writer checkpoints. Only the notification crosses tabs - the data always travels through storage, so a replica can never observe an uncommitted state:

// Writer tab
var sync = await BlazeDbTabSync.CreatePublisherAsync("mydb");
var db = await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions
{
    Storage = await BlazeDbOpfsStorage.CreateAsync("mydb"),
    OnCheckpoint = sync.PublishCheckpoint,
}.AddTable(TodoItem.Table));

// Replica tab
var replica = await BlazeDbDatabase.OpenAsync(new BlazeDbDatabaseOptions
{
    Storage = await BlazeDbOpfsStorage.CreateReadOnlyAsync("mydb"),
    ReadOnly = true,
}.AddTable(TodoItem.Table));
await BlazeDbTabSync.CreateReplicaAsync("mydb", replica, onChanged: RefreshUiAsync);

CreateReplicaAsync reloads the replica once when the writer has checkpointed since the replica opened, so a checkpoint that lands between the two calls is not missed until the next one.

CreateReadOnlyAsync returns null when the database does not exist yet, rather than creating an empty one: a replica that opens before the writer has written anything should wait for it, not invent a database of its own. The storage it returns refuses every write, so forgetting ReadOnly = true cannot turn a replica into a second, unelected writer on the same files.

A reload reads the whole generation - manifest, snapshot and log - before it touches a single row, and re-reads the manifest at the end. A checkpoint that lands halfway through is noticed and the read starts again; a reload that fails leaves the replica serving what it had.

Without OPFS

Where OPFS is unavailable or cannot be written from the main thread (Firefox private windows; Safari before 26, which can read OPFS but lacks createWritable), swap in BlazeDbIndexedDbStorage, which has the same CreateAsync / CreateReadOnlyAsync pair. IsOpfsAvailableAsync checks for the write API, not just the directory, so it says no on exactly those browsers:

IBlazeDbStorage storage = await BlazeDbIndexedDbStorage.IsOpfsAvailableAsync()
    ? await BlazeDbOpfsStorage.CreateAsync("mydb")
    : await BlazeDbIndexedDbStorage.CreateAsync("mydb");

Both backends need the Web Locks API to elect the writer. On a browser without it CreateAsync throws rather than guess; pass allowWithoutWebLocks: true if the application guarantees a single tab. A tab restored from the back/forward cache re-checks that it still holds the lock and stops writing - the data stays in memory and db.LastBackgroundError says why - if another tab has taken over in the meantime.

Bit Platform integrations

Two optional packages let other Bit Platform libraries keep their data in the application's BlazeDb database, so it shares one write-ahead log, one backup, one key for encryption at rest and one storage quota with everything else - on OPFS or IndexedDB in the browser, in files on the desktop and the server. Each library stays in charge of its own semantics; BlazeDb only stores and journals the bytes it is handed. Neither package is a dependency of the engine.

Blex state

Bit.BlazeDb.Blex is an IBlexStorage over a blex_state table: Blex still decides what is persisted, when, and how it is serialized and protected; each persisted store is one row.

// Wherever the database is opened
var options = new BlazeDbDatabaseOptions { Storage = storage }.AddTable(TodoItem.Table).AddBlexState();

// Program.cs - instead of AddBitBlexLocalStoragePersistence
builder.Services.AddBitBlexBlazeDbPersistence(async (sp, _) => await sp.GetRequiredService<AppDatabase>().GetAsync());

A save commits at once and, by default, waits until the write is on storage (flushOnSave: false leaves it to the flush interval); a value equal to the stored one is not written again. In a read-only replica tab saves fail - the writer tab owns the database - and Blex reports them; the replica re-reads every store when it reloads from the writer's checkpoints, which Blex hears about as an external change, as it does a write by another instance over the same database. The rows belong to the database, not to a user: a browser tab's database is one user's, but on a Blazor Server host give each user a database of their own, or keep Blex's per-browser storage there. new BlazeDbBlexStorage(database) builds one by hand, for a BlexStatePersistor you create yourself.

Bcrdt documents

Bit.BlazeDb.Bcrdt is an IBcrdtDocumentStore over a bcrdt_records table: Bcrdt still owns what an update means, how updates merge and when to compact; the store keeps each update as a row.

var options = new BlazeDbDatabaseOptions { Storage = storage }.AddBcrdtDocuments();
var db = await BlazeDbDatabase.OpenAsync(options);

var store = new BlazeDbBcrdtDocumentStore(db);
await using var persistence = await BcrdtDocumentPersistence.AttachAsync(document, store, "notes/42");

An append is one insert, committed in memory and journaled within the flush interval - the durability Bcrdt asks of a store, since a peer resends a record lost in a crash; call db.FlushAsync() where an edit has to be on storage at once. A compaction merges a document's records and replaces them in one transaction, and a delete removes them in one, so a crash leaves all the records or the merged one, never neither. Updates are rows like any other, held in memory; let Bcrdt compact to keep their number down.

EF Core

The provider maps entity types onto the same [BlazeDbTable] types the engine uses - the descriptor the source generator emits is the model - and is configured the same way as any other EF Core provider:

public sealed class AppContext(DbContextOptions<AppContext> options) : DbContext(options)
{
    public DbSet<TodoItem> Todos => Set<TodoItem>();
}

builder.Services.AddDbContext<AppContext>(options =>
    options.UseBlazeDb());                 // in-memory
    // options.UseBlazeDb(storage);        // OPFS, files, encrypted storage, …

var open = context.Todos.Where(t => !t.Done).OrderBy(t => t.CreatedAt).Take(20).ToList();
open[0].Done = true;
context.SaveChanges();

UseBlazeDb() with no arguments names the store after the context type, so every AppContext shares one engine. Pass a string to choose the name (UseInMemoryDatabase style), or an IBlazeDbStorage to persist. Tables are discovered from DbSet properties; there is no OpenAsync and no AddTable on this path. A store is identified by its name or its storage instance across the whole process - two contexts configured alike share one engine even when EF gives them separate internal service providers - so a persistent store never ends up with two writers.

OnConfiguring works too:

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options.UseBlazeDb();

Opening the store

When the storage backend is asynchronous (OPFS in the browser), call await context.Database.EnsureCreatedAsync() once before the first synchronous query. In-memory stores open synchronously and need no extra step. EnsureCreated returns true for the call that opened the store and false after that, so if (EnsureCreated()) Seed(); works as it does with other providers. EnsureDeleted removes the store's files as well as closing it; for a persistent store in the browser, or under a synchronization context, use EnsureDeletedAsync - the synchronous call refuses there rather than block the only thread.

What follows from memory-first

Three things are worth knowing, because they follow from the engine being memory-first rather than from anything EF does:

  • A query returns the row itself, not a copy. Mutating a tracked entity changes the database in memory immediately; SaveChanges is what commits it as one transaction and one WAL record, and what moves the secondary index entries onto the new values. Reading a value you have modified but not yet saved therefore gives you the modified one - from a query as well: while a tracked entity of that type has unsaved changes in any live context on the engine, queries read the rows instead of the indexes so both agree. (Contexts on one engine register with it for exactly this purpose; the check costs a change-detection pass over the other contexts' trackers, paid only by queries that would otherwise use an index.) What reaches storage is only ever what SaveChanges committed: the log holds committed writes, and checkpoints and exports are built from it rather than from memory, so an edit that was never saved - or whose context was discarded - is gone after the next open.
  • SaveChanges is a memory operation. It returns as soon as the transaction commits; the log reaches storage on the flush interval, or immediately if you call context.Database.GetBlazeDb().FlushAsync(). A duplicate key or unique-index violation fails the save with DbUpdateException (the engine's exception is the InnerException); the whole save is rolled back. When the save runs inside a transaction you opened - Database.BeginTransaction() or one on the engine itself - it joins that transaction behind a savepoint, so a failure part-way takes back what that save had applied and leaves the transaction as it was before the save, as a relational provider does.
  • Two contexts on one store track the same objects. Each keeps its own original values, so if both load a row and both save it, the second save describes a state the row has moved past and fails with DbUpdateConcurrencyException; reload and retry, as with any provider. Updating or deleting a row another context has since deleted is a DbUpdateConcurrencyException too. A detached entity - context.Update(dto), or Attach and then a change - is a different object from the row the store holds, so it replaces that row by key, last writer wins, as an UPDATE ... WHERE Id = @id would. A save cannot join a transaction another context has open. And after a rollback, the entities whose saves were undone are detached - the objects still hold the undone values, and the next query loads the rows as the database actually has them.

The model comes from the same attributes the engine uses: [BlazeDbKey] names the primary key whatever it is called, and [BlazeDbIgnore] keeps a property out of the entity type as well as out of the row bytes.

How LINQ is translated

LINQ is translated as far as the engine's plan model reaches: a source chosen from the predicates - the primary-key dictionary for Where(t => t.Id == id), ids.Contains(t.Id) and Find(id), a compound or single-property index for equalities (including t.Category == null) and Contains lists, an ordered index for ranges and OrderBy - then filters, ordering and paging. Everything else - projections, grouping, aggregates, joins and subqueries - runs as LINQ to Objects over the rows the plan returned, which costs nothing extra because those rows are already objects. A query may refer to other sets (context.People.Where(p => context.Orders.Any(o => o.PersonId == p.Id)), a join between two sets); those are read as scans of their tables. Two things to know: an OrderBy on a string property that an ordered index answers comes back in ordinal order, since that is how the index sorts, where LINQ to Objects would use the current culture; and Include is refused, because BlazeDb rows have no navigations - relationships are modelled as key properties and queried against the other table directly. An index can narrow a query without settling it: a comparison on a double or float stays a filter after the lookup (an index files NaN as equal to itself and below every number, which no C# operator does), and so does one against an index declared with a comparer of its own, which is not used for ranges or ordering at all.

Compiled queries

Because the provider replaces EF's query pipeline, it also replaces EF's compiled-query cache. The predicates and orderings a plan runs are compiled once per query shape and re-bound to the values each execution captures, so repeating a query does not compile anything; the expression walk that chooses the plan is repeated each time and is a few microseconds. Operators left to LINQ to Objects (a Select, a GroupBy) are evaluated by System.Linq's own enumerable query provider, which does compile them per execution - keep the heavy lifting in the Where/OrderBy/Skip/Take prefix the plan absorbs when a query is hot. EF.CompileQuery and EF.CompileAsyncQuery work as with any provider, and run through the same path: each call binds its arguments and context into the query and translates it as above.

The demo site walks through all of this against a live database at /efcore.

Schema evolution

Field numbers identify properties in the binary format. Without [BlazeDbField(n)] they follow declaration order, so adding, removing or reordering a property renumbers the ones after it and rows already on disk decode into the wrong properties without an error. Pin every property's number (1 to 536,870,911) before a type persists real data; the generator warns (BLZ013) when a type pins some numbers but not others, which is the case where an automatic number moves most easily.

Changing a property's type is safe when the old bytes cannot be misread: a field that comes back with a different wire type than the new type expects (a string that became an int, say) is skipped and reads as the default. Integer types share one wire type, though, and signed ones are zigzag-encoded while unsigned ones, char and bool are not - so changing between those groups (byte to int, uint to long, bool to int) reads every stored value wrong, without an error. Give the property a new field number for such a change and copy the values over in a migration; changes within a group (int to long, ushort to uint) are safe.

Collections (List<T>, T[]) and byte[] can be stored but not indexed, and a collection's element type has to be non-nullable: the wire format has no encoding for a null element, only for an absent field. The generator reports each of these as an error on the property rather than letting the generated code fail to compile or the index throw on its first insert.

For the same reason a string or byte[] property declared non-nullable cannot hold null once it is persisted: the null would read back as "" or an empty array and match different queries after a restart than before. A write that would persist one fails with a BlazeDbException naming the property; declare it string? to store null. (Nullable-oblivious code, and struct tables - whose strings are null whenever a row is made without them, with no compiler warning - keep the old behavior: null stored as empty.) Text that is not valid UTF-16 - a string holding half of a surrogate pair, as one cut through the middle of an emoji does - is refused the same way, because it has no exact UTF-8 encoding and would come back altered. Both checks apply to writes; a checkpoint stores whatever memory holds, so a row changed in place after its write was refused cannot stop compaction.

Compile-time diagnostics

The source generator in Bit.BlazeDb reports every table shape it cannot generate as a build diagnostic on the type or property at fault, rather than emitting code that fails to compile or a descriptor that throws at runtime. An error on the type itself - BLZ001, 002, 004, 006, 007, 010, 011, 012, 018 or 019, or a BLZ003 on a property its constructor takes - leaves it with no generated partial at all, so its own diagnostic arrives together with "does not contain a definition for 'Table'" errors wherever the type is used - fix the BLZ one first. The other errors leave out only the property or index at fault. The warnings point at something that compiles but is probably not what was meant: BLZ020 at a get-only auto-property the reader cannot restore, which is therefore not stored.

Id Severity Title
BLZ001 Error Table type must be partial
BLZ002 Error Table type must have exactly one [BlazeDbKey] property
BLZ003 Error Unsupported property type
BLZ004 Error Table type needs a constructor rows can be built with
BLZ005 Error Duplicate field number
BLZ006 Error Unsupported key type
BLZ007 Error Unsupported table type shape
BLZ008 Error Compound index references an unknown property
BLZ009 Error Compound index needs at least two properties
BLZ010 Error Duplicate index name
BLZ011 Error Index name must be a valid identifier
BLZ012 Error Table type inherits properties
BLZ013 Warning Table type mixes explicit and automatic field numbers
BLZ014 Error Field number out of range
BLZ015 Error Property cannot be indexed
BLZ016 Error Compound index lists a property twice
BLZ017 Error Indexed property cannot be stored
BLZ018 Error Required member is not stored
BLZ019 Error Name is reserved for generated members
BLZ020 Warning Get-only auto-property is not stored

Metrics

BlazeDb publishes metrics through System.Diagnostics.Metrics on a meter named Bit.BlazeDb (BlazeDbDiagnostics.MeterName), so OpenTelemetry (.AddMeter("Bit.BlazeDb")), Belemetry or dotnet-counters monitor --counters Bit.BlazeDb can collect them. They cover what decides whether data is safe and how far durability lags: commits and rollbacks, log flushes (count, bytes, duration), checkpoints (count, duration, snapshot size), recovery on open (duration, log records replayed), storage failures by operation and exception type, and quota refusals. With nobody listening each costs a single check. Reads are never measured, and no measurement carries a table name, a key or any row content.

Performance

BenchmarkDotNet, .NET 10, a 10,000-row table, against native SQLite (in memory, prepared statements). Native SQLite is a favorable proxy for sqlite-wasm solutions, which additionally pay JS interop, worker round-trips and wasm execution overhead on every query.

Operation BlazeDb SQLite Notes
Point get by key 1.2 ns (0 alloc) 743 ns ~600x
Count by hash index 2.1 ns (0 alloc) 63,020 ns O(1) vs table scan
Ordered range scan (~5,100 rows) 45.4 us 69.2 us comparable; both index-driven
Upsert (autocommit) 413 ns (with WAL) 706 ns memory-only: 311 ns
Upsert in 1,000-op transaction 369 ns/op 620 ns/op with WAL journaling

As the table grows, the first page of an ordered index stays flat - 283 ns at 100,000 rows against 274 ns at 10,000 - and the cost of persistence grows with the data: a checkpoint of 100,000 small rows takes about 5 ms - without pausing writers: it is built from what storage holds, outside the write lock - and opening that database (decoding the snapshot and rebuilding every index) about 22 ms on a desktop CPU; expect several times that in WebAssembly.

Reproduce with the suite in Tests/Bit.BlazeDb.Benchmarks (see its README). The demo app also has an in-browser micro-benchmark page (/bench) that measures the same operations in the WebAssembly runtime, including per-row storage cost.

Building

From the repository's src folder:

dotnet build Utilities/BlazeDb/Bit.BlazeDb.slnx
dotnet test --project Utilities/BlazeDb/Tests/Bit.BlazeDb.Tests        # engine and EF Core provider, net10.0/net11.0
dotnet test --project Utilities/BlazeDb/Tests/Bit.BlazeDb.Tests.Mcp    # the demo's MCP server
dotnet run --project Utilities/BlazeDb/Demo/Server                     # the demo site

The browser suite (Tests/Bit.BlazeDb.Tests.E2E) runs the demo in Chromium, Firefox or WebKit; its README says how.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
11.0.0-pre-18 0 11/12/2026