Underneath the SQL is an ordered key-value store, and you can use it on its own. Keys and values are byte arrays, keys sort, and everything the layers above rely on is here: transactions, encryption, a choice of storage engine, secondary indexes.

This is the right layer for a cache, a session store, a queue, or anything where a schema would be something to maintain rather than something that helps.

Opening one

csharp
using OutWit.Database.Core.Builder;

using var db = new WitDatabaseBuilder()
    .WithFilePath("cache.witdb")
    .WithBTree()
    .WithTransactions()
    .Build();

Or the short forms:

csharp
using var db = WitDatabase.CreateInMemory();
using var db = WitDatabase.CreateInMemory("password");

In the browser, where synchronous I/O does not exist, build asynchronously instead. See platform support.

Keys and values

Keys and values are bytes. There is no string overload, which keeps the encoding a decision you make rather than one made for you.

csharp
using System.Text;

var key = "session:8f21"u8;                 // ReadOnlySpan<byte>, no allocation
var value = Encoding.UTF8.GetBytes(payload);

db.Put(key, value);

byte[]? stored = db.Get(key);

bool existed = db.Delete(key);

Get returns null for a key that is not there. Put inserts or replaces, so there is no separate update.

The synchronous methods take ReadOnlySpan<byte>, so a UTF-8 literal like "session:8f21"u8 costs nothing. The async ones take byte[], because a span cannot cross an await:

csharp
await db.PutAsync(Encoding.UTF8.GetBytes(name), value);

byte[]? found = await db.GetAsync(Encoding.UTF8.GetBytes(name));

await db.DeleteAsync(Encoding.UTF8.GetBytes(name));

Scanning

Keys are in sorted order, which is what makes a prefix meaningful:

csharp
foreach (var (key, value) in db.Scan("session:"u8.ToArray(), "session;"u8.ToArray()))
    Console.WriteLine(Encoding.UTF8.GetString(key));

The start is inclusive and the end exclusive, and the trick above is the usual one: ; is the byte after :, so the range covers every key beginning with session: and nothing else. Design your keys so that the prefixes you will want to scan are prefixes, and this layer gives you most of what an index would have.

Passing null for either bound scans from the beginning or to the end. ScanAsync is the IAsyncEnumerable twin.

Transactions

csharp
using var transaction = db.BeginTransaction();

try
{
    transaction.Put(key1, value1);
    transaction.Put(key2, value2);

    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

The same isolation levels, savepoints and conflict behaviour as everywhere else, since it is the same transaction layer. See transactions.

Wrap a bulk write in one even when the individual operations would have worked alone. It is faster, because one commit flushes once, and it is atomic against a process that dies partway.

Secondary indexes

Values are opaque bytes to this layer, so an index over them is something you maintain rather than declare:

csharp
var index = db.CreateIndex("by-email", isUnique: true);

var existing = db.GetIndex("by-email");

db.DropIndex("by-email");

An index survives a close and reopen: what was created is recorded and restored. Keeping it in step with the data is yours to get right, which is one of the things the SQL layer above exists to do for you.

CreateIndexAsync is there for the browser, where the metadata write cannot be synchronous.

Choosing this layer

Use it when the data has no schema worth declaring, or when you are building something the SQL layer would be in the way of.

Move up to the SQL engine as soon as you find yourself maintaining indexes by hand, encoding structure into keys, or writing your own query logic over a scan. Those are the three signs that the schema exists whether or not you declared it, and declaring it costs less than maintaining it.

Where to go next