Skip to content
Nuvyntra Labs

Install

dotnet add package Plugin.Maui.LocalStore

Package ID: Plugin.Maui.LocalStore. Current package is 1.1.0 on nuget.org and GitHub Packages. Host registration is UseMauiLocalStore. Non-MAUI hosts can call services.AddMauiLocalStore(...) or LocalStore.Open(...).

Restore from GitHub Packages: Use nuvyntralabs GitHub Packages from a C# project

Define a document

public sealed class Person
{
    public string? Id { get; set; }
    public string Name { get; set; } = "";
    public int Age { get; set; }
    public string Status { get; set; } = "active";
    public string? City { get; set; }
}

GetCollection creates the table or collection on first write. The same Person type works on every engine.

Register

using Plugin.Maui.LocalStore;

builder
    .UseMauiApp<App>()
    .UseMauiLocalStore(o =>
    {
        o.Backend = StoreBackend.Sqlite;
        o.Path = Path.Combine(FileSystem.AppDataDirectory, "app.db");
        o.CreateIfMissing = true;
    });

var store = LocalStore.Current; // Backend == StoreBackend.Sqlite
var users = store.GetCollection<Person>("users");

Or without MAUI:

await using var store = LocalStore.Open(new LocalStoreOptions
{
    Backend = StoreBackend.Sqlite,
    Path = path
});

Nuvexa is the default backend. Set EncryptionKey when you open an encrypted .nvx, SQLCipher file, LiteDB password, Realm file, or Firebird SYSDBA password. Store the key in SecureStorage — not in source.

builder.UseMauiLocalStore(o =>
{
    o.Backend = StoreBackend.Nuvexa;
    o.Path = Path.Combine(FileSystem.AppDataDirectory, "app.nvx");
    o.EncryptionKey = key;
    o.CreateIfMissing = true;
    o.CacheSizeMb = 16;
});

Create, read, update, delete

The Create / Read / Update / Delete / Select samples are the same methods on every engine. Only StoreBackend and the file path change.

var id = await users.InsertAsync(new Person
{
    Name = "Ada",
    Age = 36,
    Status = "active",
    City = "London"
});
// generated when Person.Id is null; written back onto the POCO

var ids = await users.InsertManyAsync(
[
    new Person { Name = "Grace", Age = 85, Status = "retired", City = "NewYork" },
    new Person { Name = "Cara", Age = 21, Status = "active", City = "Bengaluru" },
    new Person { Name = "Alan", Age = 42, Status = "active", City = "London" }
]);

var ada = await users.FindByIdAsync(id);
if (ada is null)
{
    return;
}

ada.Name = "Ada Lovelace";
await users.ReplaceAsync(ada); // requires a non-empty Id; missing id throws LocalStoreException

var removed = await users.DeleteByIdAsync(id);
// false when the id is not present

Select and index

FindAsync with no filter returns every row. StoreQuery.Limit of 0 means no limit. SQLite and DuckDB run this as SQL. Nuvexa maps POCO names to NQL paths (Age → age, Id → _id). Key-value engines filter in memory.

var all = await users.FindAsync();

var adults = await users.FindAsync(
    StoreFilter.Gte("Age", 21),
    new StoreQuery { SortBy = "Name", Limit = 20 });

var page2 = await users.FindAsync(
    StoreFilter.Gte("Age", 21),
    new StoreQuery { SortBy = "Name", Skip = 20, Limit = 20 });

var londonActive = await users.FindAsync(
    StoreFilter.And(
        StoreFilter.Eq("City", "London"),
        StoreFilter.Eq("Status", "active")),
    new StoreQuery { SortBy = "Age", SortDescending = true });

var youngOrNy = await users.FindAsync(
    StoreFilter.Or(
        StoreFilter.Lt("Age", 30),
        StoreFilter.Eq("City", "NewYork")));

var notRetired = await users.FindAsync(StoreFilter.Ne("Status", "retired"));

await users.EnsureIndexAsync("Age");
await users.EnsureIndexAsync("City", "Status");
FilterMeaning
StoreFilter.Eq("City", "London")equal
StoreFilter.Ne("Status", "retired")not equal
StoreFilter.Gte("Age", 21)greater than or equal
StoreFilter.Lt("Age", 30)less than
StoreFilter.And(...)all children
StoreFilter.Or(...)any child

Raw SQL / NQL

Check store.QueryLanguage before QueryAsync or ExecuteAsync. SQL engines take ? placeholders. Nuvexa takes NQL. Engines (and JSON fallbacks) with QueryLanguage.None throw LocalStoreException.

if (store.QueryLanguage == StoreQueryLanguage.Sql)
{
    await store.ExecuteAsync("UPDATE users SET Status = ? WHERE City = ?", ["active", "London"]);
    var adults = await store.QueryAsync<Person>("SELECT * FROM users WHERE Age >= ?", [21]);
}

if (store.QueryLanguage == StoreQueryLanguage.Nql)
{
    var adults = await store.QueryAsync<Person>(
        """db.users.find({ age: { $gte: 21 } }).sort({ name: 1 }).limit(20)""");
}

Migrate between engines

Dispose is not enough to copy data. Set AutoMigrate and Map<T> on the destination, or call LocalStore.MigrateAsync. Destination rows win when a mapped collection is already non-empty.

builder.UseMauiLocalStore(o =>
{
    o.Backend = StoreBackend.Nuvexa;
    o.Path = Path.Combine(FileSystem.AppDataDirectory, "app.nvx");
    o.EncryptionKey = key;
    o.AutoMigrate = true;
    o.MigrateFrom = StoreBackend.Sqlite;
    o.Map<Person>("users");
});

Generated DAO

Mark an interface with [StoreDao]. CRUD names map to IStoreCollection<T>. [StoreRaw] runs QueryAsync. Resolve with store.GetDao<T>() or services.AddMauiLocalStoreDao<T>().

[StoreDao("users", typeof(Person))]
public interface IPersonDao
{
    Task<string> InsertAsync(Person item, CancellationToken cancellationToken = default);
    Task<Person?> FindByIdAsync(string id, CancellationToken cancellationToken = default);

    [StoreRaw(
        Sql = "SELECT * FROM users WHERE Age >= {minAge}",
        Nql = "db.users.find({ age: { $gte: {minAge} } })")]
    Task<IReadOnlyList<Person>> FindAdultsAsync(int minAge, CancellationToken cancellationToken = default);
}

services.AddMauiLocalStoreDao<IPersonDao>();
var dao = store.GetDao<IPersonDao>();
var adults = await dao.FindAdultsAsync(21);

Switch engine

A new engine implements ILocalStore / IStoreCollection<T> and a StoreBackend value. Host code stays on the common methods. Dispose, then Open with the new backend and a different path. Data does not copy unless AutoMigrate + Map<T> (or MigrateAsync) is set. CreateIfMissing = false throws LocalStoreException when the file is missing.

await LocalStore.Current.DisposeAsync();
LocalStore.Open(new LocalStoreOptions
{
    Backend = StoreBackend.Sqlite, // or Nuvexa, Realm, LiteDb, DuckDb, SqlCipher, Firebird, Lmdb, RocksDb, LevelDb
    Path = Path.Combine(FileSystem.AppDataDirectory, "app.db")
});

Sample

samples/Plugin.Maui.LocalStore.Sample uses the same OS TFMs as the library. MauiProgram does not call UseMauiLocalStore — the Backend picker calls LocalStore.Open so you can walk every engine. A host app that uses one engine should register it with UseMauiLocalStore.

Use insert / update / delete / find by Id, FindAsync presets, Seed, Reset file, Contract tour, and Test all engines. The 1.1 buttons run Migrate SQLite → Nuvexa, raw QueryAsync (SQL or NQL), and the generated IPersonDao. Test migrate + DAO asserts those two flows. DuckDB, Firebird, and RocksDB pass on device via the JSON fallback (CRUD only; QueryLanguage is None).

MAUI sample on GitHub

Compose with siblings

LocalStore does not reference OfflineSync, JobQueue, RetryQueue, or FileVault. Wire them yourself when the host already uses those plugins.

NeedPackage
Local CRUD across enginesPlugin.Maui.LocalStore
Offline-first sync and conflictsPlugin.Maui.OfflineSync
Durable job queue with retry / dead letterPlugin.Maui.JobQueue
Retry failed operationsPlugin.Maui.RetryQueue
Encrypted local filesPlugin.Maui.FileVault
Direct NuvexaDB / NQL / Data StudioNuventra.NuvexaDB

Discussion

Comment on Plugin.Maui.LocalStore. The thread lives on this component's GitHub repository (nuvyntralabs/Plugin.Maui.LocalStore). Sign in with GitHub — Giscus uses Discussions, Utterances uses Issues.