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
using OutWit.Database.Core.Builder;
using var db = new WitDatabaseBuilder()
.WithFilePath("cache.witdb")
.WithBTree()
.WithTransactions()
.Build();Or the short forms:
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.
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:
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:
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
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:
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
- Architecture, the layers this sits on
- Storage engines, what
WithBTreeselects - SQL engine, the layer above
- Encryption,
WithEncryption