Install
dotnet add package Plugin.Maui.LocalStorePackage 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 presentSelect 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");| Filter | Meaning |
|---|---|
| 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).
Compose with siblings
LocalStore does not reference OfflineSync, JobQueue, RetryQueue, or FileVault. Wire them yourself when the host already uses those plugins.
| Need | Package |
|---|---|
| Local CRUD across engines | Plugin.Maui.LocalStore |
| Offline-first sync and conflicts | Plugin.Maui.OfflineSync |
| Durable job queue with retry / dead letter | Plugin.Maui.JobQueue |
| Retry failed operations | Plugin.Maui.RetryQueue |
| Encrypted local files | Plugin.Maui.FileVault |
| Direct NuvexaDB / NQL / Data Studio | Nuventra.NuvexaDB |