Lightweight local SQLite data + cache abstraction for .NET apps.
- LocaliteStore — simple key/value cache with TTL (expiration), ETags, and JSON POCOs/dynamic.
- LocaliteContext — small EF-style ORM on top of sqlite-net-pcl (
DbObjSet<T>, raw SQL helpers, graph saves) with high-performance reflection caching. - Optimized — Built for speed with bulk write efficiency and concurrent write serialization.
- Authenticated encryption — Optional AES-GCM protection for selected fields and cache payloads, using application-managed keys.
Targets: .NET Standard 2.1 and .NET 8 or higher
Supports :memory: databases for testing and other scenarios.
Current: Version 1.3.11
- Install
- Storage Paths & Cross-Platform Support
- LocaliteStore (key/value cache)
- LocaliteContext (SQLite ORM)
- Encryption
- Tips
- License
Package install
dotnet add package LwXLabs.LocaliteSQLite and its native assets are transitive package dependencies. Consumers should not add separate versions unless they have a tested application-specific requirement.
Localite includes storage-path handling for Windows, Android, iOS, and Linux. The 1.3.11 package is validated on Windows and Linux and by a packaged-consumer smoke app on an Android 15 / API 35 x64 emulator. iOS applications should still validate their final packaged build on an iOS device or simulator.
The Android smoke test checks private app-local storage, native SQLite loading, plain and AES-GCM-protected LocaliteStore writes, LocaliteContext schema creation and CRUD, reads, disposal, and database reopening. With an emulator already running, maintainers can repeat it with ./scripts/Test-AndroidPackage.ps1 -PackagePath ./artifacts/public-ready-final/LwXLabs.Localite.1.3.11.nupkg.
By default, Localite resolves the database storage path automatically to a robust, writable app-local folder:
- On Android/iOS, it uses the internal application data folder (via
FileSystem.AppDataDirectorywhere available). - On Windows, it uses
LocalApplicationData. - On Linux, it falls back to
LocalApplicationDataorPersonalfolders.
The resolution logic is hardened to avoid read-only root directories on mobile platforms.
- ApplicationId: Used to namespace your storage folder. Defaults to
Localite.Store.ApporLocalite.Context.App. - Directory: (Optional) Explicitly override the base directory. If left
null(default), Localite chooses the best absolute path for the current platform. - DbName: The filename for your SQLite database (e.g.,
MyData.db3).
opt.ApplicationId = "MyUniqueAppId"; // Highly recommended
opt.DbName = "Storage.db3";
// opt.Directory remains null to use auto-resolved absolute pathusing Localite;
// Demo only. Persistent apps must load a stable random key from platform secure storage.
byte[] encryptionKey = System.Security.Cryptography.RandomNumberGenerator.GetBytes(32);
using var store = LocaliteStore.Create(
applicationId: "Localite.Store.Demo",
databaseName: ":memory:",
encryptionKey: encryptionKey
);
// Primitive
store.Add("num:1", 42, expireIn: TimeSpan.FromMinutes(5), secure: false);
int? v = store.Get<int>("num:1", options: null, decrypt: false);
// POCO
store.Add("user:1", new { Name = "Ana", Age = 30 }, TimeSpan.FromMinutes(1), secure: false);
var user = store.Get<dynamic>("user:1", decrypt: false);
// Dynamic JSON (encrypted)
store.Add("dyn:1", new { name = "dyn", on = true }, TimeSpan.FromMinutes(1), secure: true);
dynamic d = store.GetDynamic("dyn:1", decrypt: true)!;
Console.WriteLine((string)d.name); // "dyn"using Localite;
using Microsoft.Extensions.DependencyInjection;
var sc = new ServiceCollection();
sc.AddLocalite() //or builder.Services.AddLocalite
.UseCache(opt =>
{
opt.ApplicationId = "My.App";
opt.DbName = ":memory:"; // or "Datasaver.db3"
opt.EncryptionKey = Convert.FromBase64String(configuration["LocaliteKey"]!); // securely stored random 32-byte key
// opt.AutoExpire = true; opt.CleanupOnStart = true;
});
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<ILocaliteStore>();
store.Add("k", "v", TimeSpan.FromMinutes(5), secure: true);
var v = store.Get<string>("k", options: null, decrypt: true);
Console.WriteLine(v); // "v".NET Standard 2.1
var sc = new ServiceCollection();
sc.AddLocalite()
.UseCache("store1", o =>
{
o.ApplicationId = "Test.App";
o.DbName = "TestDb1";
o.Directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Localite", "Test");
})
.UseCache("store2", o =>
{
o.ApplicationId = "Test.App2";
o.DbName = "TestDb2";
o.Directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Localite", "Test");
});
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var provider = scope.ServiceProvider.GetRequiredService<ILocaliteStoreProvider>();
var store1 = provider.Get("store1");
var store2 = provider.Get("store2");Keyed (.NET 8+):
var sc = new ServiceCollection();
sc.AddLocalite()
.UseKeyedCache("store1", o => { /* opts */ })
.UseKeyedCache("store2", o => { /* opts */ });
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var s1 = scope.ServiceProvider.GetRequiredKeyedService<ILocaliteStore>("store1");
var s2 = scope.ServiceProvider.GetRequiredKeyedService<ILocaliteStore>("store2");store.Exists("k"); // bool
store.IsExpired("k"); // bool
store.GetETag("k"); // string?
store.GetExpiration("k"); // DateTime?
store.GetKeys(); // IEnumerable<string> (active by default)
store.Remove("k1", "k2"); // bulk remove
store.RemoveExpired(); // deletes expired rows now
store.RemoveAll(); // truncate cacheSchema warning:
EnsureTablesAsyncis a convenience migration mechanism, not a replacement for tested production migrations.SchemaSyncMode.RebuildIfDifferentcan copy data into a temporary table, drop the original table, and rename the replacement. Test every schema change against a copy of real data and enableRebuildBackupbefore using rebuild mode in production.
using Localite;
[Table("Customers")]
public class Customer
{
[PrimaryKey, AutoIncrement] public int Id { get; set; }
[NotNull] public string Name { get; set; } = "";
}
public class Tables : DefaultTableSetup
{
public DbObjSet<Customer> Customers { get; } = null!;
}using Localite;
using var ctx = LocaliteContext.Create(new LocaliteContextOptions
{
DbName = ":memory:",
EnsureCreated = true, // this will force the ctx.EnsureTablesAsync
TableSetupOptions = new TableSetupOptions
{
Tables = typeof(Tables),
AutoMigrate = true,
CreateIndexes = true,
AutoDetectManyToMany = true,
SchemaSync = SchemaSyncMode.RebuildIfDifferent
}
});
// Insert
await ctx.Set<Customer>()
.Insert(new Customer { Name = "Eve" })
.SaveAsync();
// Query
bool any = ctx.Set<Customer>().Any(c => c.Name == "Eve");
// Raw SQL (parameters)
var rows1 = ctx.Set<Customer>()
.FromSql("SELECT * FROM Customers WHERE Name = ?", "Eve")
.ToList();
// Raw SQL (interpolated)
string name = "Eve";
var rows2 = ctx.Set<Customer>()
.FromSqlInterpolated($"SELECT * FROM Customers WHERE Name = {name}")
.ToList();
// Transactions
ctx.InTransaction(() =>
{
ctx.Set<Customer>().Insert(new Customer { Name = "Bob" }).Save();
});using Localite;
using Microsoft.Extensions.DependencyInjection;
var sc = new ServiceCollection();
sc.AddLocalite()
.UseContext(opts =>
{
opts.ApplicationId = "Test.App";
opts.DbName = "AppDb.db3"; // or ":memory:"
opts.Directory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "Localite", "Demo");
opts.EnsureCreated = true; // Ensurer runs when tables provided
opts.TableSetupOptions = new TableSetupOptions
{
};
});
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ILocaliteContext>();
await ctx.Set<Customer>().Insert(new Customer { Name = "Alice" }).SaveAsync();using Localite;
using Microsoft.Extensions.DependencyInjection;
var sc = new ServiceCollection();
sc.AddLocalite()
.UseContext(opts =>
{
opts.ApplicationId = "Test.App";
opts.TableSetupOptions = new TableSetupOptions
{
Tables = typeof(Tables),
};
});
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ILocaliteContext>();
await ctx.Set<Customer>().Insert(new Customer { Name = "Alice" }).SaveAsync();sc.AddLocalite()
.UseContextFromTables<Tables>(opts =>
{
opts.ApplicationId = "Test.App";
opts.DbName = "AppDb.db3";
opts.TableSetupOptions = new TableSetupOptions
{ // no tables needed here
};
});
var ctxB = sp.GetRequiredService<ILocaliteContext>();
ctxB.Set<Customer>().Insert(new Customer { Name = "Bob" }).Save();public class AppDbContext : LocaliteContext<Tables>
{
public AppDbContext(LocaliteContextOptions opts) : base(opts) { }
public DbObjSet<Customer> Customers => Sets.Customers;
}
sc.AddLocalite()
.UseContext<AppDbContext>(opts =>
{
opts.ApplicationId = "Test.App";
opts.DbName = "AppDb.db3";
opts.TableSetupOptions = new TableSetupOptions
{
};
});
var concrete = sp.GetRequiredService<AppDbContext>();
concrete.Customers.Insert(new Customer { Name = "Charlie" }).Save();public interface ICustomersContext : ILocaliteContext
{
DbObjSet<Customer> Customers { get; }
}
public class InterfaceContext : LocaliteContext<ICustomersContext>
{
public InterfaceContext(LocaliteContextOptions opts) : base(opts) { }
public DbObjSet<Customer> Customers => Sets.Customers;
}
sc.AddLocalite()
.UseContext<ICustomersContext, InterfaceContext>(opts =>
{
opts.ApplicationId = "Test.App";
opts.DbName = "AppDb.db3";
opts.TableSetupOptions = new TableSetupOptions
{
};
});
var ctx = sp.GetRequiredService<ICustomersContext>();
ctx.Customers.Insert(new Customer { Name = "Dana" }).Save();.net standard 2.1
var sc = new ServiceCollection();
sc.AddLocalite()
.UseContext("ctx1", opts => {
opts.ApplicationId = "Test.App1";
opts.DbName = "Ctx1.db3";
opts.TableSetupOptions = new TableSetupOptions {
Tables = typeof(Tables),
};
})
.UseContext("ctx2", opts => {
opts.ApplicationId = "Test.App2";
opts.DbName = "Ctx2.db3";
opts.TableSetupOptions = new TableSetupOptions {
Tables = typeof(Tables),
};
});
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
var provider = scope.ServiceProvider.GetRequiredService<ILocaliteContextProvider>();
var ctx1 = provider.Get("ctx1");
var ctx2 = provider.Get("ctx2");Keyed (.NET 8+):
var sc = new ServiceCollection();
sc.AddLocalite()
.UseKeyedContext("ctx1", opts => { /* Tables = typeof(Tables), opts... */ })
.UseKeyedContext("ctx2", opts => { /* Tables = typeof(Tables), opts... */ });
using var sp = sc.BuildServiceProvider();
using var scope = sp.CreateScope();
// Plain ILocaliteContext
var c1 = scope.ServiceProvider.GetRequiredKeyedService<ILocaliteContext>("ctx1");
var c2 = scope.ServiceProvider.GetRequiredKeyedService<ILocaliteContext>("ctx2");
// With concrete context
sc = new ServiceCollection();
sc.AddLocalite()
.UseKeyedContext<AppDbContext>("ctxA", opts => { /* opts... */ })
.UseKeyedContext<AppDbContext>("ctxB", opts => { /* opts... */ });
using var sp2 = sc.BuildServiceProvider();
using var scope2 = sp2.CreateScope();
var a = scope2.ServiceProvider.GetRequiredKeyedService<AppDbContext>("ctxA");
var b = scope2.ServiceProvider.GetRequiredKeyedService<AppDbContext>("ctxB");
// With interface-driven context
sc = new ServiceCollection();
sc.AddLocalite()
.UseKeyedContext<ICustomersContext, InterfaceContext>("customers1", opts => { /* opts... */ })
.UseKeyedContext<ICustomersContext, InterfaceContext>("customers2", opts => { /* opts... */ });
using var sp3 = sc.BuildServiceProvider();
using var scope3 = sp3.CreateScope();
var i1 = scope3.ServiceProvider.GetRequiredKeyedService<ICustomersContext>("customers1");
var i2 = scope3.ServiceProvider.GetRequiredKeyedService<ICustomersContext>("customers2");Data warning: Exports and rebuild backups can contain plaintext when decrypted secure values are requested. Protect backup files and prefer envelope encryption for sensitive data.
var dump = await ctx.ExportAllAsync(new ExportOptions { ... });await ctx.ExportAllToFileAsync(backupPath, new ExportOptions { ... });await ctx2.ImportAsync(dump, new ImportOptions { InsertMode = ImportInsertMode.InsertOrReplace });await ctx.ImportFromFileAsync(
backupPath,
new ImportOptions
{
InsertMode = ImportInsertMode.InsertOrReplace,
OnMissingSourceNotNull = DataPreservationBehavior.BackfillTypeDefaults,
SourceColumnResolver = (table, col) =>
table == "Customers_X" && col.OriginalColumnName == "FullName" ? "Name" : null,
PreserveWarn = msg => Debug.WriteLine(msg)
});ctx.RelationModel
ctx.TableSetupOptions
ctx.InTransaction()
ctx.Vacuum()
ctx.Analyze()
ctx.WalCheckpoint()
ctx.EnsureTablesAsync()
ctx.ExportAllAsync()
ctx.ExportTableAsync()
ctx.ExportAllToFileAsync()
ctx.ExportTableToFileAsync()
ctx.ImportAsync()
ctx.ImportFromFileAsync() SchemaSetupCompleted -> SchemaSetupCompletedEvent
SchemaSetupProgressChanged -> SchemaSetupProgressChangedEvent For high-performance scenarios where you only need a subset of columns, use SelectColumns. This reduces data movement from SQLite and avoids unnecessary object population/allocations.
// Fetches only the 'Id' and 'Name' columns into the Customer entity
var customers = ctx.Customers
.Where(c => c.IsActive)
.SelectColumns(c => new { c.Id, c.Name })
.ToList();
// Alternative syntax with multiple expressions
var customers = ctx.Customers
.SelectColumns(c => c.Id, c => c.Email)
.ToList();
// All other properties on the returned Customer objects will have default values.With every ctx.EnsureTableAsync, The relationmodel class gets updated to the latest relations.
It supports 1:1, 1:M, M:N, Indexing.
Use [ManyToMany] Attribute to enforce M:N
RelationModel
- List<RelationRow> Rows
- Dictionary<string, Type> TableToType
- Dictionary<Type, string> TypeToTable
- Dictionary<string, HashSet<string>> DependsOn
- List<ManyToManyMeta> ManyToMany
- List<IndexSpec> IndexesSecurity warning: Localite encryption has not been independently audited and is not full-database encryption. It protects only values explicitly written as secure; database metadata, indexes, unmarked values, and encryption keys are outside its protection. Keep keys in platform secure storage and perform a security review before relying on it for high-risk data.
Authenticated decryption failures, including a wrong key or modified ciphertext, throw a cryptographic exception. Do not suppress that failure or treat the encrypted payload as application data.
New built-in AES-GCM payloads authenticate their intended cache key or database table/column as associated data. Moving a valid ciphertext to a different protected location therefore fails authentication. Payloads written by the earlier version-1 format remain readable for migration.
Calling .WithDecrypt() for protected fields or decrypt: true for cache entries is strict: unauthenticated plaintext, an incorrect key, modified ciphertext, or a value moved from another protected location is rejected.
To enable encryption, set a default encryption provider (e.g., with a symmetric key) using LocaliteStoreOptions or TableSetupOptions. Then, mark fields with the [SecureLocal] attribute to have them encrypted/decrypted automatically.
Localite supports encryption for string and byte[] fields marked with [SecureLocal]. Id, Primary and Foreign flags will be ignored.
Class level [SecureLocal] is also supported, that will encrypt all the valid fields.
If option.EncryptionKey is not set or no own ILocaliteEncryption provided, no encryption is performed and an error is thrown.
If you want to encrypt/decrypt individual cache entries in LocaliteStore, use the secure: true flag in Add and decrypt: true in Get methods.
In LocaliteContext, use .Secure() on save operations (Insert/Update/Upsert) to enable encryption, and .WithDecrypt() on queries to enable decryption of secure fields.
// store or context options
options.EncryptionKey = Convert.FromBase64String(configuration["LocaliteKey"]!); // securely stored random 16-, 24-, or 32-byte key
// Custom encryption provider
options.Crypto = CustomEncryptionProvider;
// Global encryption behaviors
options.DefaultSecureGlobal = false; // If true, encrypts all eligible string/byte[] fields with the configured provider
options.DefaultSaveSecureLocalFlag = false; // If true, automatically encrypts [SecureLocal] fields during savesSet a cryptographically random 32-byte key in options to use AES-256-GCM. Store the key with the platform's secure credential/key storage; do not hard-code it or pass a human password directly. Exact 16- and 24-byte keys use AES-128-GCM and AES-192-GCM. Other key sizes are rejected; derive a key from passwords with a suitable password-based key derivation function before configuring Localite.
Earlier builds accepted other key sizes by applying SHA-256 once. To migrate existing encrypted data, compute that same 32-byte SHA-256 result from the old key bytes once, store the resulting key securely, and configure Localite with that exact 32-byte value. A single SHA-256 operation is only a compatibility migration and is not suitable password derivation.
Use the ILocaliteEncryption interface to implement your own encryption logic.
class CustomEncryptionProvider : ILocaliteEncryption
{
public string Encrypt(string plain)
{
// Implement your encryption logic here
return protectedData;
}
public string Decrypt(string cipher)
{
// Implement your decryption logic here
return decryptedData;
}
}using Localite;
public class SecureData
{
[PrimaryKey, AutoIncrement] public int Id { get; set; } // Ignored by encryption
[NotNull] public string Name { get; set; } = ""; // Ignored by encryption
[SecureLocal] public string SecretNote { get; set; } = "";
[SecureLocal] public byte[] SecretBlob { get; set; } = Array.Empty<byte>();
}LocaliteStore
_store.Add(key, poco, expireIn: TimeSpan.FromMinutes(5), secure: true);
Assert.Throws<System.Text.Json.JsonException>(() =>
{
var encrypt = _store.Get<CachePoco>(key, options: null, decrypt: false);
});
var back = _store.Get<CachePoco>(key, options: null, decrypt: true);
Assert.NotNull(back);LocaliteContext
var cust = new FakeCustomer { Name = "Bob" };
// .Secure() triggers encryption of [SecureLocal] fields before saving
ctx.Customers.Insert(cust).Secure().Save();
// Searching by encrypted field directly will not find it (as it's stored encrypted)
var nullObj = ctx.Customers.FirstOrDefault(x => x.Name == "Bob");
Assert.Null(nullObj);
// Standard fetch returns encrypted data
var foundEncObj = ctx.Customers.FirstOrDefault(x => x.Id == cust.Id);
Assert.NotNull(foundEncObj);
Assert.NotEqual("Bob", foundEncObj.Name);
// .WithDecrypt() decrypts the entity after fetching from DB
var okObj = ctx.Customers.WithDecrypt().FirstOrDefault(x => x.Id == cust.Id);
Assert.NotNull(okObj);
Assert.Equal("Bob", okObj.Name);- Resource Management: Always dispose of
LocaliteStoreandLocaliteContextwhen not using DI. Both implementIDisposableto ensure database connections and encryption resources are properly released. - Performance: High-performance reflection caching and optimized bulk writes are enabled by default. For the best performance in heavy write scenarios, use
.InsertAllSave()or wrap multiple operations in actx.InTransaction()block. - Schema Migrations: Use
SchemaSyncMode.RebuildIfDifferentfor automated migrations. EnableRebuildBackupinTableSetupOptionsto create a JSON safety-net before any destructive changes. - Advanced SQLite Options: Fine-tune performance with
JournalMode(defaults to WAL),Synchronous,PageSize, andCacheSizePagesinLocaliteContextOptions. - Concurrent Writes:
SerializeWrites(true by default) ensures that multiple threads don't collide when writing to the same database file. - Use
:memory:database names for ephemeral in-memory databases that vanish when the connection is closed. - Wrap tests in transactions and roll them back to keep the database clean:
using var ctx = ...; // your LocaliteContext
ctx.InTransaction(() =>
{
// perform test operations here
// changes will be rolled back after this block
});-
Make sure to use different Directory/ DbNames to avoid collisions.
-
Use
LocaliteStore.CreateandLocaliteContext.Createfor direct instantiation. -
Use unique keys or prefixes in
LocaliteStoreto avoid collisions. -
If using
LocaliteStore, consider enablingAutoExpireandCleanupOnStartto manage cache state. -
Use SqlLogger options to capture SQL statements and operations for debugging.
.Insert().WithLogger(e => Debug.WriteLine(e))- In
LocaliteContext, usectx.EnsureTablesAsync(options)to handle manual table configuration. - Use
EnsureCreatedandAutoMigrateoptions to automatically set up the database schema. - Use
SeedAsyncinLocaliteContextOptionsto populate initial data. - For performance-sensitive tests, consider adjusting
BusyTimeout,CacheSizePages, and other SQLite options to simulate different environments. - Use
DefaultWriteTxModeto control transaction behavior. - For complex object graphs, use
DefaultSaveCascadeor[ForeignKey(OnDelete, OnUpdate)]and related options to test cascading saves and deletes. - Insert
FromSqlandFromSqlInterpolatedmethods to fetch raw SQL queries. - Use
Insert,Update, andDeleteand other methods CRUD operations on entities. - Methods ending
Asyncwill also work to save the CRUD operation. Otherwise use.Save()or.SaveAsync() WARNINGUse ctx.entities.UseCache()with care. Cache removed after each session.WARNINGUse ctx.entities.Insert().LogSaves()with care. Each time a log gets saved along the record which decreases the write speed.
Licensed under the Apache License 2.0.
See NOTICE.txt and THIRD-PARTY-NOTICES.txt for additional notices.
Copyright © 2025–2026 lwxlabs.