WALnutDB 1.0.6

dotnet add package WALnutDB --version 1.0.6
                    
NuGet\Install-Package WALnutDB -Version 1.0.6
                    
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="WALnutDB" Version="1.0.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="WALnutDB" Version="1.0.6" />
                    
Directory.Packages.props
<PackageReference Include="WALnutDB" />
                    
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 WALnutDB --version 1.0.6
                    
#r "nuget: WALnutDB, 1.0.6"
                    
#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 WALnutDB@1.0.6
                    
#: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=WALnutDB&version=1.0.6
                    
Install as a Cake Addin
#tool nuget:?package=WALnutDB&version=1.0.6
                    
Install as a Cake Tool

WALnutDB

A simple, safe embedded database for .NET 8+. Built for devices with little RAM and slow flash (SD/eMMC). It uses Write-Ahead Logging (WAL) for power-loss durability, compacts data into sorted segment files (SST), and provides secondary indexes and time-series scans. Fully managed (no native deps), cross-platform, and async by default.

WALnutDB mascot

NuGet License .NET

Highlights

  • Power-loss safety: WAL with per-frame CRC, truncation-tolerant replay.
  • Async I/O everywhere: designed for slow media and small RAM.
  • Document tables: pluggable serialization (e.g., System.Text.Json); GUID/string/byte[] keys.
  • Secondary indexes: range scans over string/bytes/ints/floats/decimal (with scale).
  • Time-series mode: UTC-ordered keys, efficient range scans by time.
  • Checkpoint & SST: flush memtables to sorted segments, fast reads, WAL truncation.
  • Cross-platform: Linux/Windows/macOS; file-per-table for fault isolation.
  • No native dependencies: pure C# for easy deployment on IoT.

Roadmap: online compaction/defrag stats & swap, unique index enforcement, optional encryption-at-rest, richer query push-downs.


Install

dotnet add package WALnutDB

Target framework: .NET 8.0+


Quick Start

using System.Text.Json;
using WalnutDb;
using WalnutDb.Core;
using WalnutDb.Wal;

// Sample POCO
public sealed class User
{
    [DatabaseObjectId] public string Id { get; set; } = "";
    [DbIndex("Age")]   public int Age { get; set; }
    public string Name { get; set; } = "";
}

// Create database directory
var dir = Path.Combine(Path.GetTempPath(), "walnut-demo");
Directory.CreateDirectory(dir);

// WAL writer (group-commit, fsync per batch)
await using var wal = new WalWriter(Path.Combine(dir, "wal.log"));

// DB (managed, no native deps)
await using var db = new WalnutDatabase(
    directory: dir,
    options: new DatabaseOptions(),
    manifest: new FileSystemManifestStore(dir),
    wal: wal
);

// Open a table (default serializer is used unless you override it)
var users = await db.OpenTableAsync(new TableOptions<User>
{
    GetId = u => u.Id, // string / Guid / byte[] supported
    // Optional custom serializer:
    //Serialize   = u => JsonSerializer.SerializeToUtf8Bytes(u),
    //Deserialize = b => JsonSerializer.Deserialize<User>(b.Span)!,
    StoreGuidStringsAsBinary = true // optional optimization
});

// Upsert + Get
await users.UpsertAsync(new User { Id = "u1", Name = "Ada", Age = 37 });
var ada = await users.GetAsync("u1");
Console.WriteLine($"{ada?.Id}: {ada?.Name} ({ada?.Age})");

// Enumerate all
await foreach (var u in users.GetAllAsync())
    Console.WriteLine($"{u.Id}: {u.Name} ({u.Age})");

// Durability checkpoint (flush mem→SST, truncate WAL)
await db.CheckpointAsync();

Secondary Indexes

Declare indexes:

public sealed class Product
{
    [DatabaseObjectId] public string Id { get; set; } = "";

    [DbIndex("Category")]
    public string Category { get; set; } = "";

    [DbIndex("Price", decimalScale: 2)] // decimals require a fixed scale
    public decimal Price { get; set; }
}

Open table and scan ranges (decimal + string prefix):

using System.Text.Json;
using WalnutDb.Indexing;

var products = await db.OpenTableAsync(new TableOptions<Product> {
    GetId = p => p.Id,
    // Optional custom serializer:
    //Serialize   = p => JsonSerializer.SerializeToUtf8Bytes(p),
    //Deserialize = b => JsonSerializer.Deserialize<Product>(b.Span)!
});

// Insert a few
await products.UpsertAsync(new Product { Id = "A", Category = "sensors",   Price = 12.50m });
await products.UpsertAsync(new Product { Id = "B", Category = "sensors",   Price = 18.75m });
await products.UpsertAsync(new Product { Id = "C", Category = "actuators", Price = 33.40m });

// Range by decimal index (inclusive/exclusive semantics)
var from = IndexKeyCodec.Encode(10m, decimalScale: 2);
var to   = IndexKeyCodec.Encode(20m, decimalScale: 2);

await foreach (var p in products.ScanByIndexAsync("Price", from, to))
    Console.WriteLine($"{p.Id} {p.Price:0.00}");

// Range by string prefix (e.g., category "sensors")
var start = IndexKeyCodec.Encode("sensors");
var end   = IndexKeyCodec.PrefixUpperBound(start);

await foreach (var p in products.ScanByIndexAsync("Category", start, end))
    Console.WriteLine($"{p.Id} {p.Category}");

Index hints

You can steer scans using IndexHint factories. (Descending Asc=false is planned; current implementation returns ascending.)

using WalnutDb.Indexing;

// Price in [10.00, 20.00), ascending, skip first 5, take 10
var hint = IndexHint.FromValues("Price", 10.00m, 20.00m, decimalScale: 2, asc: true, skip: 5, take: 10);
await foreach (var p in products.QueryAsync(x => true, hint))
    Console.WriteLine($"{p.Id} {p.Price:0.00}");

// Prefix by category:
var hint2 = IndexHint.FromPrefix("Category", "sensors");
await foreach (var p in products.QueryAsync(_ => true, hint2))
    Console.WriteLine($"{p.Id} {p.Category}");

Unique indexes

Mark a property with [DbIndex(Name, Unique = true)]. null values do not participate in uniqueness (SQL-like behavior).

public sealed class User
{
    [DatabaseObjectId] public string Id { get; set; } = "";
    [DbIndex("Email", Unique = true)]
    public string? Email { get; set; }
}

// Insert A with email X, then checkpoint
await users.UpsertAsync(new User { Id = "A", Email = "x@example.com" });
await db.CheckpointAsync();

// Inserting B with the same email throws
await Assert.ThrowsAsync<InvalidOperationException>(() =>
    users.UpsertAsync(new User { Id = "B", Email = "x@example.com" }));

// Deleting A releases the unique constraint immediately (in mem)
// and after checkpoint (for SST-backed entries)
await users.DeleteAsync("A");
await db.CheckpointAsync();
await users.UpsertAsync(new User { Id = "B", Email = "x@example.com" });

Time-Series

Provide a series id and a UTC timestamp. Keys are encoded so that lexicographic byte order matches chronological order.

public sealed class SensorSample
{
    [DatabaseObjectId] public string Id { get; set; } = ""; // optional
    public string  DeviceId { get; set; } = "";
    public DateTime Utc     { get; set; }       // must be UTC
    public double  Temperature { get; set; }
}

var ts = await db.OpenTimeSeriesAsync(new TimeSeriesOptions<SensorSample>
{
    GetSeriesId     = s => s.DeviceId,
    GetUtcTimestamp = s => s.Utc,
    // Optional custom serializer:
    //Serialize       = s => JsonSerializer.SerializeToUtf8Bytes(s),
    //Deserialize     = b => JsonSerializer.Deserialize<SensorSample>(b.Span)!,
});

// Append
await ts.AppendAsync(new SensorSample { DeviceId = "dev-1", Utc = DateTime.UtcNow, Temperature = 22.3 });

// Query by time range
var fromUtc = DateTime.UtcNow.AddMinutes(-10);
var toUtc   = DateTime.UtcNow;

await foreach (var s in ts.QueryAsync("dev-1", fromUtc, toUtc))
    Console.WriteLine($"{s.Utc:o} {s.Temperature:0.0}");

Transactions

Both automatic (implicit) and manual (explicit) transactions are supported.

// Auto: each call is an individual transaction
await users.UpsertAsync(new User { Id = "u2", Name = "Linus", Age = 49 });

// Manual: batch multiple ops → single WAL fsync
await using (var tx = await db.BeginTransactionAsync())
{
    await users.UpsertAsync(new User { Id = "u3", Name = "Grace", Age = 44 }, tx);
    await users.DeleteAsync("u1", tx);
    await tx.CommitAsync(Durability.Group);
}

Encryption at rest

WALnutDB can encrypt values at rest (WAL + SST) with AES-GCM(256). Keys and index keys remain plaintext for sortability.

Encrypted: table values written to WAL and SST.
Not encrypted: primary keys, index keys (they include the index value), filenames/metadata.
Threat model: protects against offline reads of WAL/SST files. In-memory data is plaintext.

var dir = Path.Combine(Path.GetTempPath(), "walnut-enc");
await using var wal = new WalWriter(Path.Combine(dir, "wal.log"));

var key = Convert.FromHexString("00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF");

await using var db = new WalnutDatabase(
    dir,
    new DatabaseOptions { Encryption = new AesGcmEncryption(key) },
    new FileSystemManifestStore(dir),
    wal);

var tbl = await db.OpenTableAsync(new TableOptions<MyDoc> { GetId = d => d.Id });
await tbl.UpsertAsync(new MyDoc { Id = "x", Secret = "hello" });
await db.CheckpointAsync(); // values written to SST are ciphertext

Crash-recovery with encryption: recovery verifies crc32 and replays committed frames, decrypting values on the fly before applying them to memtables.


Durability & Checkpoints

  • await db.FlushAsync() — fsync WAL (fast).
  • await db.CheckpointAsync() — flush memtables → SST and truncate the WAL.
  • Recovery reads wal.log, verifies the CRC of each frame, replays committed transactions, and safely stops at a torn tail.

For safe shutdown on embedded devices:

await db.CheckpointAsync();  // shortens recovery time and shrinks the WAL

Preflight Checks

Verify permissions, free space, and ability to acquire exclusive locks:

var report = await db.PreflightAsync(dir, reserveBytes: 4 * 1024 * 1024);
Console.WriteLine($"{report.FileSystem} free={report.FreeBytes} CanExclusive={report.CanExclusiveLock}");

Benchmarks

In this repo there is BenchmarkDotNet (WalnutDb.Bench). Run in Release:

dotnet run -c Release --project WalnutDb.Bench

On-disk formats (v1)

WAL (v1)

Stream of frames:

[len:U32_LE] [payload:len bytes] [crc32:U32_LE]
  • crc32 is computed over payload (polynomial 0xEDB88320).
  • payload layout varies by op:
    • Begin: [op:1][txId:U64][seqNo:U64]
    • Put: [op:1][txId:U64][tlen:U16][klen:U32][vlen:U32][table:tlen][key:klen][value:vlen]
    • Del: [op:1][txId:U64][tlen:U16][klen:U32][table:tlen][key:klen]
    • Commit:[op:1][txId:U64][opsCount:U32] (reserved)
  • Replay stops on the first malformed or truncated frame (“crash tail”).
  • If encryption is enabled, values inside WAL frames are encrypted (AES‑GCM v1) with AAD=[table|pk].

SST (v1)

Flat sorted segment file:

Header:  "SSTv1\0\0\0"  (8 bytes)
Records: repeated [klen:U32_LE][vlen:U32_LE][key:klen][value:vlen]
Trailer: [count:U32_LE] (optional in future versions)
  • Keys are sorted lexicographically; merges assume sorted input.
  • If encryption is enabled, values stored in SST are ciphertext (AES‑GCM v1, same AAD).

Filenames

Each logical table name is encoded to a filesystem‑safe base64‑url (without padding) when stored on disk. Example: ordersb3JkZXJz.


Notes & Limitations

  • IndexHint.Asc=false (descending scans) is planned; current implementation returns ascending.
  • Unique indexes: null value does not participate in uniqueness.
  • One SST per table in v1 (simple model suitable for embedded; background compaction will come later).

License

MIT © WALnutDB contributors

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

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
1.0.6 27 8/29/2025
1.0.5 30 8/28/2025
1.0.4 127 8/21/2025
1.0.3 125 8/21/2025
1.0.2 120 8/20/2025
1.0.1 121 8/20/2025