Skip to content
lwxlabsPublic

About

Lightweight local SQLite data and cache abstraction for .NET applications.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Localite

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


Table of Contents


Install

Package install

dotnet add package LwXLabs.Localite

SQLite and its native assets are transitive package dependencies. Consumers should not add separate versions unless they have a tested application-specific requirement.

Storage Paths & Cross-Platform Support

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.AppDataDirectory where available).
  • On Windows, it uses LocalApplicationData.
  • On Linux, it falls back to LocalApplicationData or Personal folders.

The resolution logic is hardened to avoid read-only root directories on mobile platforms.

Configuration

  • ApplicationId: Used to namespace your storage folder. Defaults to Localite.Store.App or Localite.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 path

LocaliteStore (key/value cache)

Quickstart (no DI)

using 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 DI extensions

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"

Multiple stores (named & keyed)

.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 Helpers

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 cache

LocaliteContext (SQLite ORM)

Schema warning: EnsureTablesAsync is a convenience migration mechanism, not a replacement for tested production migrations. SchemaSyncMode.RebuildIfDifferent can 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 enable RebuildBackup before using rebuild mode in production.

Define entities & setup

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!;
}

Direct usage (no DI)

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 DI extensions

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();

1. Plain ILocaliteContext (tables in options)

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();

2. UseContextFromTables<T> (DefaultTableSetup subclass)

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();

3. Concrete context derived from LocaliteContext<TTables>

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();

4. Interface-driven context (proxy sets)

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();

Multiple contexts (named & keyed)

.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");

Import / Export

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.

Export

var dump = await ctx.ExportAllAsync(new ExportOptions { ... });
await ctx.ExportAllToFileAsync(backupPath, new ExportOptions { ... });

Import

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)
    });

Context Helpers

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()

Context Events

 SchemaSetupCompleted          -> SchemaSetupCompletedEvent 
 SchemaSetupProgressChanged    -> SchemaSetupProgressChangedEvent 

Selective Column Fetching (Projections)

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.

Relations and RelationModel

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> Indexes

Encryption

Security 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.

Setup encryption

// 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 saves

Default AES-GCM

Set 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.

Using own cryptography

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;
    }
}

Defining secure fields

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>(); 
}

Example

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);

Tips

  • Resource Management: Always dispose of LocaliteStore and LocaliteContext when not using DI. Both implement IDisposable to 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 a ctx.InTransaction() block.
  • Schema Migrations: Use SchemaSyncMode.RebuildIfDifferent for automated migrations. Enable RebuildBackup in TableSetupOptions to create a JSON safety-net before any destructive changes.
  • Advanced SQLite Options: Fine-tune performance with JournalMode (defaults to WAL), Synchronous, PageSize, and CacheSizePages in LocaliteContextOptions.
  • 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.Create and LocaliteContext.Create for direct instantiation.

  • Use unique keys or prefixes in LocaliteStore to avoid collisions.

  • If using LocaliteStore, consider enabling AutoExpire and CleanupOnStart to manage cache state.

  • Use SqlLogger options to capture SQL statements and operations for debugging.

.Insert().WithLogger(e => Debug.WriteLine(e))
  • In LocaliteContext, use ctx.EnsureTablesAsync(options) to handle manual table configuration.
  • Use EnsureCreated and AutoMigrate options to automatically set up the database schema.
  • Use SeedAsync in LocaliteContextOptions to populate initial data.
  • For performance-sensitive tests, consider adjusting BusyTimeout, CacheSizePages, and other SQLite options to simulate different environments.
  • Use DefaultWriteTxMode to control transaction behavior.
  • For complex object graphs, use DefaultSaveCascade or [ForeignKey(OnDelete, OnUpdate)] and related options to test cascading saves and deletes.
  • Insert FromSql and FromSqlInterpolated methods to fetch raw SQL queries.
  • Use Insert, Update, and Delete and other methods CRUD operations on entities.
  • Methods endingAsync will also work to save the CRUD operation. Otherwise use .Save() or .SaveAsync()
  • WARNING Use ctx.entities.UseCache() with care. Cache removed after each session.
  • WARNING Use ctx.entities.Insert().LogSaves() with care. Each time a log gets saved along the record which decreases the write speed.

License

Licensed under the Apache License 2.0.
See NOTICE.txt and THIRD-PARTY-NOTICES.txt for additional notices.

Copyright © 2025–2026 lwxlabs.

About

Lightweight local SQLite data and cache abstraction for .NET applications.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages