IndexedDb
A structured, transactional client-side database for serious amounts of data. Butil covers the whole surface - schema migrations, key ranges, cursors, indexes, atomic batches and binary values - without the ceremony of wiring up request and transaction event handlers by hand.
@inject Bit.Butil.IndexedDb indexedDbMDN reference
IndexedDB is available in every modern browser, but private-browsing modes and unusual embedding contexts can restrict it - feature-detect when your app depends on it.
@inject Bit.Butil.IndexedDb indexedDb
if (await indexedDb.IsSupported())
{
// safe to open databases
}Open connects to (and creates if needed) the named database, returning a live IndexedDbHandle. The schema is applied inside the upgrade transaction and is idempotent - missing stores and indexes are created, an index whose definition changed is re-created, and a store marked Drop is removed. Pass version: null to attach to whatever version is already on disk instead of naming one. The handle reports what the open did: OldVersion, NewVersion and WasUpgraded.
private IndexedDbHandle db = default!;
db = await indexedDb.Open("butil-docs-notes", version: 2, stores:
[
new IndexedDbStoreSchema
{
Name = "notes",
KeyPath = "id",
Indexes =
[
new IndexedDbIndexSchema { Name = "by-category", KeyPath = "category" },
],
},
new IndexedDbStoreSchema { Name = "blobs" }, // out-of-line keys
],
onVersionChange: () => Console.WriteLine("another tab is upgrading; this handle was closed"),
onClose: () => Console.WriteLine("connection died - storage evicted?"));
if (db.WasUpgraded)
{
// Runs after the upgrade transaction commits, so keep any backfill idempotent.
Console.WriteLine($"migrated v{db.OldVersion} -> v{db.NewVersion}");
}Add inserts a new record and throws when the key already exists; Put upserts - insert or overwrite. Records are plain C# objects, JSON-serialized on the way in. Because the store's keypath is id, the key comes from the record itself. Both return the record's key, which is the only way to read back what an autoIncrement store generated. A write resolves once its transaction commits, not merely once the request succeeds, so a completed call means the data is durable.
{
// The handle from "Open a database" above; every call below goes through it.
private IndexedDbHandle db = default!;
// The record type. Property names are camel-cased on the way across, so "id" is the KeyPath the
// store was created with.
public class Note
{
public int Id { get; set; }
public string Text { get; set; } = "";
public string Category { get; set; } = "general";
}
private async Task Write()
{
var key = await db.Add("notes", new Note { Id = 1, Text = "Hello IndexedDB", Category = "intro" });
await db.Put("notes", new Note { Id = 1, Text = "Hello again", Category = "intro" }); // upsert
}
}Get reads a single record by key, GetAll returns the whole store (optionally capped by a count), GetAllKeys lists just the keys, and Count reports how many records the store holds. GetRaw is the untyped sibling of Get, returning a JsonElement when no static type fits.
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Read()
{
var one = await db.Get<Note>("notes", 1); // null when absent
var all = await db.GetAll<Note>("notes"); // Note[]
var keys = await db.GetAllKeys("notes"); // JsonElement[]
var count = await db.Count("notes"); // int
}
}A key range turns an exact-key lookup into an interval query - the main reason to reach for IndexedDB over simpler storage. Ranges go anywhere a key does: reads, counts, deletes and cursor walks all take one, so a single range object drives the whole operation set.
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Query()
{
// IndexedDbKeyRange.Bound(10, 20); // 10 <= key <= 20
// IndexedDbKeyRange.Bound(10, 20, lowerOpen: true); // 10 < key <= 20
// IndexedDbKeyRange.LowerBound(10); // key >= 10
// IndexedDbKeyRange.UpperBound(20, open: true); // key < 20
// IndexedDbKeyRange.Only(15); // key == 15
var range = IndexedDbKeyRange.Bound(10, 20);
var notes = await db.GetAll<Note>("notes", range);
var keys = await db.GetAllKeys("notes", range);
var howMany = await db.Count("notes", range);
await db.Delete("notes", range); // removes the whole interval
}
}GetPage walks the store with a real cursor, which is how you paginate, read newest-first, or skip - none of which GetAll can express. Each record carries the keys the cursor saw alongside the value. The walk runs entirely on the JS side and hands back a materialized page: an IndexedDB transaction goes inactive as soon as control returns to the event loop, and every interop call does exactly that, so a cursor cannot be stepped one record at a time from .NET.
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Paginate()
{
var page = await db.GetPage<Note>("notes", skip: 20, take: 10);
foreach (var record in page)
{
Console.WriteLine($"{record.PrimaryKey}: {record.Value!.Text}");
}
// newest first
var latest = await db.GetPage<Note>("notes", direction: IndexedDbCursorDirection.Previous, take: 5);
// keys only, restricted to a range
var keys = await db.GetKeyPage("notes", IndexedDbKeyRange.LowerBound(10), take: 3);
}
}Indexes let you look records up by a property other than the primary key. The demo database declares a by-category index over the category property. Everything the store supports works on an index too - exact keys, ranges, counts and cursor walks - plus DeleteByIndex, which an index has no native equivalent for: it walks a cursor and deletes each match by primary key inside one transaction, so it removes all of them or none. Walking with NextUnique collapses duplicates, which is how you enumerate the distinct values an index holds.
{
// The index has to exist in the schema the database was opened with - "by-category" is the one
// declared on the notes store above.
private IndexedDbHandle db = default!;
private async Task ByIndex()
{
var first = await db.GetByIndex<Note>("notes", "by-category", "intro");
var matches = await db.GetAllByIndex<Note>("notes", "by-category", "intro");
var keys = await db.GetAllKeysByIndex("notes", "by-category", "intro");
var howMany = await db.CountByIndex("notes", "by-category", "intro");
var removed = await db.DeleteByIndex("notes", "by-category", "intro"); // returns the count
// every distinct category, once each
var distinct = await db.GetKeyPageByIndex("notes", "by-category",
direction: IndexedDbCursorDirection.NextUnique);
}
}Transact runs every operation in one transaction spanning all the stores they touch, so a failure anywhere rolls the whole batch back. Operations are submitted together rather than issued one at a time - the same event-loop constraint that stops cursors being stepped from .NET stops a transaction being held open across calls, so passing them as a batch is what makes the atomicity real. It also means one transaction instead of one per write, which is markedly faster in bulk. The returned array holds each operation's resulting key, in order.
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Batch()
{
var keys = await db.Transact(
[
IndexedDbOperation.Put("notes", new Note { Id = 10, Text = "first", Category = "batch" }),
IndexedDbOperation.Put("notes", new Note { Id = 11, Text = "second", Category = "batch" }),
IndexedDbOperation.Delete("notes", 1),
],
durability: IndexedDbDurability.Relaxed);
// An Add on a key that already exists aborts the batch - none of the writes above it survive.
}
}Values normally cross the interop boundary as JSON, which cannot carry raw bytes - base64 inflates them by a third and large payloads hit string-length limits. PutBytes keeps the payload binary the whole way and stores it as an ArrayBuffer. Read it back with GetBytes, not Get: the stored record is not JSON, so Get cannot deserialize it. The demo writes into the blobs store, which has no keypath, so the key is passed explicitly.
{
// "blobs" is the out-of-line store from the schema above, so the key is passed rather than read
// off the value.
private IndexedDbHandle db = default!;
private async Task Binary(byte[] png)
{
await db.PutBytes("blobs", png, key: "logo");
var bytes = await db.GetBytes("blobs", "logo"); // byte[]? - null when absent or not binary
}
}Read back what the database actually looks like rather than assuming the schema you asked for is the schema you got - useful after an upgrade, or when another part of the app owns the versioning. Databases lists this origin's databases (an empty result also means the browser cannot enumerate them - Firefox only shipped that in 126). Compare orders two keys exactly as IndexedDB does, which is the only reliable way to reason about key ordering without guessing.
Bit.Butil.IndexedDb indexedDb
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Inspect()
{
var info = await db.GetInfo(); // name, version, store names
var store = await db.GetStoreInfo("notes"); // keypath, autoIncrement, index names
var index = await db.GetIndexInfo("notes", "by-category"); // keypath, unique, multiEntry
// These two are on the service rather than a connection: they answer about the origin.
var all = await indexedDb.Databases(); // IndexedDbDatabaseInfo[]
var order = await indexedDb.Compare(1, 2); // negative: 1 sorts before 2
}
}Delete removes a single record by key; Clear empties the store while keeping its schema and indexes intact.
{
private IndexedDbHandle db = default!; // from "Open a database"
private async Task Remove()
{
await db.Delete("notes", 1);
await db.Clear("notes");
}
}The handle owns the underlying IDBDatabase connection - dispose it when you are done so the connection closes. DeleteDatabase removes the database entirely; close any open handle first, otherwise the browser blocks the deletion until the connection goes away.
IAsyncDisposable
Bit.Butil.IndexedDb indexedDb
{
private IndexedDbHandle? db;
// A deleteDatabase request blocks for as long as any connection is still open - including this
// page's own - so the handle is closed first.
private async Task Drop()
{
if (db is not null) await db.DisposeAsync();
db = null;
await indexedDb.DeleteDatabase("butil-docs-notes");
}
public async ValueTask DisposeAsync()
{
if (db is not null) await db.DisposeAsync();
}
}Open is called with a version number higher than the stored one. Opening with the
same version never touches the schema - to add a store or index to an existing database, bump the
version. Opening with a lower version than the stored one fails outright; pass
version: null to attach to whatever is on disk. Data migration is yours to do: check
WasUpgraded and OldVersion after the open and keep the backfill
idempotent, since it necessarily runs after the upgrade transaction has already committed.
GetPage walks the cursor and returns a page,
Transact submits the batch. Each still runs inside one real IDB transaction on the
JS side, so the ordering and atomicity guarantees are the genuine ones.
DateTime arrives in JavaScript as an ISO-8601 string
rather than a Date - which still sorts correctly, as long as every key in the store
was written the same way. For binary payloads use PutBytes/GetBytes,
which bypass JSON entirely.
API reference
ValueTask<bool> IsSupported()ValueTask<IndexedDbHandle> Open(string name, int? version = 1, IndexedDbStoreSchema[]? stores = null, Action? onVersionChange = null, Action? onClose = null, Action? onBlocked = null)ValueTask DeleteDatabase(string name)ValueTask<IndexedDbDatabaseInfo[]> Databases()ValueTask<int> Compare(object first, object second)Guid Id { get; }string DatabaseName { get; } int Version { get; } string[] StoreNames { get; }int OldVersion { get; } int NewVersion { get; } bool WasUpgraded { get; }ValueTask<JsonElement> Put<T>(string store, T value, object? key = null)ValueTask<JsonElement> Add<T>(string store, T value, object? key = null)ValueTask<JsonElement> PutBytes(string store, byte[] data, object? key = null)ValueTask<byte[]?> GetBytes(string store, object query)ValueTask<T?> Get<T>(string store, object query)ValueTask<JsonElement> GetRaw(string store, object query)ValueTask<JsonElement> GetKey(string store, object query)ValueTask<T[]> GetAll<T>(string store, int? count = null) | GetAll<T>(string store, IndexedDbKeyRange range, int? count = null)ValueTask<JsonElement[]> GetAllKeys(string store, int? count = null) | GetAllKeys(string store, IndexedDbKeyRange range, int? count = null)ValueTask Delete(string store, object query)ValueTask Clear(string store)ValueTask<int> Count(string store, object? query = null)ValueTask<T?> GetByIndex<T>(string store, string index, object query)ValueTask<JsonElement> GetKeyByIndex(string store, string index, object query)ValueTask<T[]> GetAllByIndex<T>(string store, string index, object query, int? count = null)ValueTask<JsonElement[]> GetAllKeysByIndex(string store, string index, object query, int? count = null)ValueTask<int> CountByIndex(string store, string index, object? query = null)ValueTask<int> DeleteByIndex(string store, string index, object query)ValueTask<IndexedDbRecord<T>[]> GetPage<T>(string store, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)ValueTask<IndexedDbKeyRecord[]> GetKeyPage(string store, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)ValueTask<IndexedDbRecord<T>[]> GetPageByIndex<T>(string store, string index, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)ValueTask<IndexedDbKeyRecord[]> GetKeyPageByIndex(string store, string index, object? query = null, IndexedDbCursorDirection direction = Next, int skip = 0, int take = 0)ValueTask<JsonElement[]> Transact(IndexedDbOperation[] operations, IndexedDbTransactionMode mode = ReadWrite, IndexedDbDurability durability = Default)ValueTask<IndexedDbDatabaseInfo?> GetInfo() | ValueTask<IndexedDbStoreInfo?> GetStoreInfo(string store) | ValueTask<IndexedDbIndexInfo?> GetIndexInfo(string store, string index)ValueTask DisposeAsync()static IndexedDbKeyRange Only(object) | LowerBound(object, bool open = false) | UpperBound(object, bool open = false) | Bound(object, object, bool lowerOpen = false, bool upperOpen = false)static IndexedDbOperation Put<T>(string store, T value, object? key = null) | Add<T>(...) | Delete(string store, object query) | Clear(string store)enum { Next, NextUnique, Previous, PreviousUnique }enum { Default, Relaxed, Strict }class IndexedDbRecord<T> { JsonElement Key; JsonElement PrimaryKey; T? Value; }class IndexedDbStoreSchema { string Name; string? KeyPath; string[]? KeyPaths; bool AutoIncrement; bool Drop; IndexedDbIndexSchema[] Indexes; }class IndexedDbIndexSchema { string Name; string KeyPath; string[]? KeyPaths; bool Unique; bool MultiEntry; bool Drop; }