Durable 0.3.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Durable --version 0.3.0
                    
NuGet\Install-Package Durable -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Durable" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Durable" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Durable" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Durable --version 0.3.0
                    
#r "nuget: Durable, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Durable@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Durable&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Durable&version=0.3.0
                    
Install as a Cake Tool

<div align="center"> <img src="https://github.com/jchristn/Durable/blob/main/assets/logo.png" width="182" height="182"> </div>

Durable ORM

NuGet Durable.MySql NuGet Durable.Postgres NuGet Durable.Sqlite NuGet Durable.SqlServer

IMPORTANT Durable is in ALPHA. We appreciate your patience, feedback, and willingness to test this library in its early stages. We welcome feedback, issues, and constructive criticism in the Issues and Discussions

A lightweight .NET ORM library with LINQ capabilities, designed with a clean, generic architecture that allows developers to build custom repository implementations without being constrained by opinionated base classes.

Quick Start - Hello World

Here's a complete working example using SQLite:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Durable;
using Durable.Sqlite;

// 1. Define your entity
[Entity("people")]
public class Person
{
    [Property("id", Flags.PrimaryKey | Flags.AutoIncrement)]
    public int Id { get; set; }

    [Property("first_name", Flags.String, 64)]
    public string FirstName { get; set; }

    [Property("last_name", Flags.String, 64)]
    public string LastName { get; set; }

    [Property("birthday")]
    public DateTime Birthday { get; set; }
}

// 2. Use the repository
public class Program
{
    public static async Task Main()
    {
        // Create repository (file-based database)
        SqliteRepository<Person> repo = new SqliteRepository<Person>("Data Source=myapp.db");

        // Initialize the table (creates if not exists)
        repo.InitializeTable(typeof(Person));

        // Create five records
        List<Person> people = new List<Person>
        {
            new Person { FirstName = "Alice",   LastName = "Smith",    Birthday = new DateTime(1990, 3, 15) },
            new Person { FirstName = "Bob",     LastName = "Johnson",  Birthday = new DateTime(1985, 7, 22) },
            new Person { FirstName = "Carol",   LastName = "Williams", Birthday = new DateTime(1992, 11, 8) },
            new Person { FirstName = "David",   LastName = "Brown",    Birthday = new DateTime(1988, 1, 30) },
            new Person { FirstName = "Eve",     LastName = "Davis",    Birthday = new DateTime(1995, 5, 12) }
        };

        IEnumerable<Person> created = await repo.CreateManyAsync(people);
        Console.WriteLine("Created 5 records:");
        foreach (Person p in created)
        {
            Console.WriteLine($"  {p.Id}: {p.FirstName} {p.LastName} - {p.Birthday:yyyy-MM-dd}");
        }

        // Retrieve and display all records
        Console.WriteLine("\nAll records:");
        IEnumerable<Person> all = repo.ReadAll().ToList();
        foreach (Person p in all)
        {
            Console.WriteLine($"  {p.Id}: {p.FirstName} {p.LastName} - {p.Birthday:yyyy-MM-dd}");
        }

        // Modify all records (add 1 year to birthday)
        Console.WriteLine("\nUpdating birthdays...");
        foreach (Person p in all)
        {
            p.Birthday = p.Birthday.AddYears(1);
            await repo.UpdateAsync(p);
        }

        // Display modified records
        Console.WriteLine("\nModified records:");
        foreach (Person p in repo.ReadAll())
        {
            Console.WriteLine($"  {p.Id}: {p.FirstName} {p.LastName} - {p.Birthday:yyyy-MM-dd}");
        }

        // Delete all records
        int deleted = repo.DeleteAll();
        Console.WriteLine($"\nDeleted {deleted} records.");
    }
}

Why Durable?

Durable sits between Dapper and Entity Framework: typed LINQ queries, CRUD, relationships, optimistic concurrency and schema creation, without a DbContext, change tracking or a migrations system, and with SQL you can always see.

Key Benefits

  • No configuration overhead: no DbContext, no model builder; attributes when you want control, conventions when you don't
  • Parameterized, predictable SQL: every value is a parameter; BuildSql(), CaptureSql and tracing show exactly what runs
  • No change tracking: entities are plain objects; opt-in optimistic concurrency with version columns
  • One engine, four databases: SQLite, MySQL, PostgreSQL and SQL Server share a single SQL engine (Durable.Sql) behind a small dialect interface, so behavior and fixes are identical across providers
  • Fast materialization: per-type metadata and compiled accessors are cached; rows map by ordinal
  • Async from the ground up: true streaming with IAsyncEnumerable, cancellation everywhere
  • Backend-neutral core: IRepository<T> and IQueryBuilder<T> in the Durable package contain no SQL concepts, leaving room for non-SQL backends

Packages

Package Contents
Durable Backend-neutral contracts: IRepository<T>, IQueryBuilder<T>, attributes, EntityMetadata, transactions, conflict resolvers, diagnostics
Durable.Sql Shared SQL engine: ISqlRepository<T>, ISqlQueryBuilder<T>, ISqlDialect, LINQ-to-SQL translation, includes, executor, interceptors
Durable.Sqlite / Durable.MySql / Durable.Postgres / Durable.SqlServer Dialect, connection factory and repository for each database

Requirements

  • .NET 8.0 or later (tested on .NET 8 and .NET 10)
  • Database versions:
    • SQLite 3.35+ (bundled with Microsoft.Data.Sqlite)
    • MySQL 8.0.31+ (via MySqlConnector 2.6)
    • PostgreSQL 12+ (via Npgsql 10)
    • SQL Server 2017+ (via Microsoft.Data.SqlClient 7)

Installation

# SQLite
dotnet add package Durable.Sqlite

# MySQL
dotnet add package Durable.MySql

# PostgreSQL
dotnet add package Durable.Postgres

# SQL Server
dotnet add package Durable.SqlServer

Database Provider Setup

SQLite

using Durable.Sqlite;

// Using connection string
SqliteRepository<Person> repo = new SqliteRepository<Person>("Data Source=myapp.db");

// Using settings object
SqliteRepositorySettings settings = new SqliteRepositorySettings
{
    DataSource = "myapp.db",
    Mode = SqliteOpenMode.ReadWriteCreate,
    CacheMode = SqliteCacheMode.Shared
};
SqliteRepository<Person> repo = new SqliteRepository<Person>(settings);

MySQL

# Quick start with Docker
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=mydb mysql:8
using Durable.MySql;

// Using connection string
MySqlRepository<Person> repo = new MySqlRepository<Person>(
    "Server=localhost;Database=mydb;User=root;Password=password;");

// Using settings object
MySqlRepositorySettings settings = new MySqlRepositorySettings
{
    Hostname = "localhost",
    Database = "mydb",
    Username = "root",
    Password = "password",
    Port = 3306,
    SslMode = MySqlSslMode.Preferred
};
MySqlRepository<Person> repo = new MySqlRepository<Person>(settings);

PostgreSQL

# Quick start with Docker
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=mydb postgres:16
using Durable.Postgres;

// Using connection string
PostgresRepository<Person> repo = new PostgresRepository<Person>(
    "Host=localhost;Database=mydb;Username=postgres;Password=password;");

// Using settings object
PostgresRepositorySettings settings = new PostgresRepositorySettings
{
    Hostname = "localhost",
    Database = "mydb",
    Username = "postgres",
    Password = "password",
    Port = 5432,
    SslMode = SslMode.Prefer
};
PostgresRepository<Person> repo = new PostgresRepository<Person>(settings);

SQL Server

# Quick start with Docker
docker run -d -p 1433:1433 -e ACCEPT_EULA=Y -e SA_PASSWORD=YourStrong@Passw0rd mcr.microsoft.com/mssql/server:2022-latest
using Durable.SqlServer;

// Using connection string
SqlServerRepository<Person> repo = new SqlServerRepository<Person>(
    "Server=localhost;Database=mydb;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=true;");

// Using settings object
SqlServerRepositorySettings settings = new SqlServerRepositorySettings
{
    Hostname = "localhost",
    Database = "mydb",
    Username = "sa",
    Password = "YourStrong@Passw0rd",
    TrustServerCertificate = true,
    Encrypt = false
};
SqlServerRepository<Person> repo = new SqlServerRepository<Person>(settings);

Defining Entities

Map a class with [Entity] and [Property]:

using Durable;

[Entity("people")]
public class Person
{
    [Property("id", Flags.PrimaryKey | Flags.AutoIncrement)]
    public int Id { get; set; }

    [Property("first_name", Flags.String, 64)]
    public string FirstName { get; set; }

    [Property("email", Flags.String, 128)]
    public string? Email { get; set; }

    [Property("salary")]
    public decimal Salary { get; set; }

    [Property("birth_date")]
    public DateTime? BirthDate { get; set; }

    // Enums are stored by name by default...
    [Property("status")]
    public Status Status { get; set; }

    // ...or as integers with Flags.Integer
    [Property("priority", Flags.Integer)]
    public Priority Priority { get; set; }

    // Collections and complex objects are stored as JSON (jsonb on PostgreSQL)
    [Property("tags", Flags.Json)]
    public List<string> Tags { get; set; } = new List<string>();
}

Conventions

A class with no [Property] attributes maps every scalar read/write property by name. Id (or {TypeName}Id) is the key and is auto-increment when it is an integer. Use [NotMapped] to skip a property. DurableMapping.NamingConvention = NamingConvention.SnakeCase maps FirstName to first_name.

public class Note            // table "Note", columns Id, Title, CreatedUtc
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public DateTime CreatedUtc { get; set; }
    [NotMapped] public string Preview => Title.Length > 20 ? Title[..20] : Title;
}

Composite keys

Mark several properties as Flags.PrimaryKey and order them with KeyOrder. Key arguments take an object[]:

[Entity("enrollments")]
public class Enrollment
{
    [Property("student_id", Flags.PrimaryKey, KeyOrder = 0)] public int StudentId { get; set; }
    [Property("course_id", Flags.PrimaryKey, KeyOrder = 1)] public int CourseId { get; set; }
    [Property("grade")] public string? Grade { get; set; }
}

Enrollment? e = await repo.ReadByIdAsync(new object[] { 42, 7 });

Value converters

public class CsvListConverter : ValueConverter<List<string>, string>
{
    public override string ConvertToProvider(List<string> value) => string.Join(",", value);
    public override List<string> ConvertFromProvider(string value) => value.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
}

[Property("labels")]
[ValueConverter(typeof(CsvListConverter))]
public List<string> Labels { get; set; } = new();

Converters also apply to values compared against the column in Where predicates.

Basic CRUD Operations

Person created = await repo.CreateAsync(new Person { FirstName = "John", Salary = 75000m });
Person? found = await repo.ReadByIdAsync(created.Id);
List<Person> adults = repo.ReadMany(p => p.Salary > 50000).ToList();   // streamed

found!.Salary = 80000m;
await repo.UpdateAsync(found);

await repo.DeleteByIdAsync(found.Id);
int deleted = await repo.DeleteManyAsync(p => p.Salary < 1000);

// Set-based updates in one statement
await repo.BatchUpdateAsync(p => p.Status == Status.Pending, p => new Person { Salary = p.Salary * 1.05m });
await repo.UpdateFieldAsync(p => p.Email == null, p => p.Status, Status.Inactive);

// Inserts with generated keys written back, in input order
IEnumerable<Person> inserted = await repo.CreateManyAsync(people);

// Fastest path, no key write-back: SqlBulkCopy / PostgreSQL COPY / MySqlBulkCopy / prepared SQLite inserts
long rows = await repo.BulkInsertAsync(manyPeople);

// Native upsert (ON CONFLICT / ON DUPLICATE KEY / MERGE)
await repo.UpsertAsync(person);

Query Builder

List<Person> page = (await repo.Query()
    .Where(p => p.Salary > 100000 && p.Email != null)
    .Where(p => p.FirstName.StartsWith("Jo"))
    .OrderByDescending(p => p.Salary)
    .Skip(20).Take(10)
    .ExecuteAsync()).ToList();

// Streaming
await foreach (Person p in repo.Query().Where(p => p.Status == Status.Active).ExecuteAsyncEnumerable()) { }

// Projection computed in SQL; Where/OrderBy apply to projected members
List<PersonSummary> summaries = (await repo.Query()
    .Select(p => new PersonSummary { Name = p.FirstName + " " + p.LastName, Monthly = p.Salary / 12 })
    .Where(s => s.Monthly > 5000)
    .OrderBy(s => s.Name)
    .ExecuteAsync()).ToList();

// Grouping with HAVING, computed by the database
List<DepartmentStats> stats = (await repo.Query()
    .GroupBy(p => p.Department)
    .Having(g => g.Count() > 2)
    .Select(g => new DepartmentStats { Department = g.Key, Headcount = g.Count(), Payroll = g.Sum(p => p.Salary) })
    .ExecuteAsync()).ToList();

// Navigation predicates become subqueries
List<Author> prolific = (await authors.Query().Where(a => a.Books.Count() > 3).ExecuteAsync()).ToList();
List<Book> byAcme = (await books.Query().Where(b => b.Author.Company.Name == "Acme").ExecuteAsync()).ToList();

Supported in predicates: comparisons (with C# null semantics), &&/||/!, arithmetic, string concatenation, ??, ternaries, enums, HasValue/.Value, Contains/StartsWith/EndsWith (wildcards escaped), case-insensitive Equals/Contains via StringComparison, ToUpper/ToLower/Trim/Substring/Replace/IndexOf/Length, string.IsNullOrEmpty, collection Contains (IN), date parts and Add* methods, Math functions, Between/In/NotIn helpers, and Any/All/Count over collection navigations.

Null comparisons follow C# semantics (x.A != x.B and !(x.N > 1) include rows where a nullable operand is NULL). String equality, LIKE and Replace follow the database collation (for example, case- and accent-insensitive on MySQL's default collation), as in EF Core.

ISqlQueryBuilder<T> adds Union/UnionAll/Intersect/Except, WhereIn/WhereExists subqueries, WhereRaw("col = {0}", value) (placeholders are parameters), CTEs, window functions and SelectCase().

Relationships

[Entity("books")]
public class Book
{
    [Property("id", Flags.PrimaryKey | Flags.AutoIncrement)] public int Id { get; set; }
    [Property("author_id")] [ForeignKey(typeof(Author), "Id")] public int AuthorId { get; set; }
    [NavigationProperty("AuthorId")] public Author? Author { get; set; }
}

[Entity("authors")]
public class Author
{
    [Property("id", Flags.PrimaryKey | Flags.AutoIncrement)] public int Id { get; set; }
    [InverseNavigationProperty("AuthorId")] public List<Book> Books { get; set; } = new();
    [ManyToManyNavigationProperty(typeof(AuthorCategory), "AuthorId", "CategoryId")] public List<Category> Categories { get; set; } = new();
}

List<Author> withBooks = (await authors.Query()
    .Include(a => a.Books).ThenInclude<Book, Company?>(b => b.Publisher)
    .Include(a => a.Categories)
    .OrderBy(a => a.Name).Take(20)          // 20 authors, each with all of their books
    .ExecuteAsync()).ToList();

Includes load with one query per navigation, keyed by the parent rows. There is no cartesian explosion, and Skip/Take count parent rows only.

Query Filters and Soft Delete

repo.AddQueryFilter(o => o.TenantId == tenantContext.TenantId);  // evaluated per query

[Property("deleted_utc")] [SoftDelete] public DateTime? DeletedUtc { get; set; }
await repo.DeleteAsync(order);                                   // sets deleted_utc instead of deleting
List<Order> everything = (await repo.Query().IgnoreQueryFilters().ExecuteAsync()).ToList();

Transactions

using ISqlTransaction tx = await repo.BeginTransactionAsync();
await repo.CreateAsync(order, tx);
await lines.CreateManyAsync(orderLines, tx);
ISavepoint sp = await tx.CreateSavepointAsync();
await tx.CommitAsync();                       // dispose without commit rolls back

// Ambient scope across awaits
using (TransactionScope scope = await TransactionScope.CreateAsync(repo))
{
    await repo.CreateAsync(a);                // joins the scope automatically
    await scope.CompleteAsync();
}

// Join a transaction opened by Dapper/EF/ADO.NET
await repo.CreateAsync(entity, SqlTransactionContext.Wrap(connection, transaction, PostgresDialect.Default));

Connections

Durable uses each driver's own connection pooling. Share one factory across repositories and dispose it at shutdown. Repositories never dispose a factory they were given. See CONNECTION_MGMT.md.

PostgresConnectionFactory factory = new PostgresConnectionFactory(connectionString, maxConcurrentConnections: 50);
PostgresRepository<Person> people = new PostgresRepository<Person>(factory);

Optimistic Concurrency

[Property("version")]
[VersionColumn(VersionColumnType.Integer)]
public int Version { get; set; } = 1;

try { await repo.UpdateAsync(author); }
catch (OptimisticConcurrencyException) { /* reload and retry */ }

repo.ConflictResolver = new ClientWinsResolver<Author>();   // or DatabaseWinsResolver, MergeChangesResolver

Diagnostics

SqlRepositoryOptions options = new SqlRepositoryOptions
{
    Logger = loggerFactory.CreateLogger("Durable"),   // Debug per command, Warning when slow, Error on failure
    SlowCommandThreshold = TimeSpan.FromMilliseconds(200),
    CommandTimeoutSeconds = 30
};
options.Interceptors.Add(new MyCommandInterceptor());   // ISqlCommandInterceptor
SqliteRepository<Person> repo = new SqliteRepository<Person>(connectionString, options);

// OpenTelemetry
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource(DurableDiagnostics.ActivitySourceName));

// SQL capture
repo.CaptureSql = true;
List<Person> rows = repo.ReadMany(p => p.Salary > 25).ToList();
Console.WriteLine(repo.LastExecutedSql);                 // parameterized SQL
Console.WriteLine(repo.LastExecutedSqlWithParameters);   // with values, for debugging only
string sql = repo.Query().Where(p => p.Salary > 25).BuildSql();

Raw SQL, Procedures and Multiple Result Sets

List<Person> rows = repo.FromSql("SELECT * FROM people WHERE salary BETWEEN @p0 AND @p1", null, 50000, 100000).ToList();
List<TopEarner> dtos = repo.FromSql<TopEarner>("SELECT first_name, salary FROM people ORDER BY salary DESC").ToList(); // snake_case -> PascalCase
long total = repo.ExecuteScalar<long>("SELECT COUNT(*) FROM people");
int affected = await repo.ExecuteSqlAsync("UPDATE people SET salary = salary * 1.05 WHERE department = @p0", null, default, "Engineering");

using SqlMultipleResultReader multi = repo.QueryMultiple("SELECT * FROM people; SELECT COUNT(*) FROM people");
List<Person> people = multi.Read<Person>();
long count = multi.Read<long>()[0];

List<Person> fromProc = repo.FromProcedure<Person>("get_people_by_department", null, new SqlParameterValue("@department", "Sales"));

Table Initialization

repo.InitializeTable(typeof(Person));                    // CREATE TABLE if missing, indexes, column validation
repo.InitializeTables(new[] { typeof(Person), typeof(Author), typeof(Book) });
bool isValid = repo.ValidateTable(typeof(Person), out List<string> errors, out List<string> warnings);

License

This project is licensed under the MIT License - see the LICENSE.md file for details.

Contributors

Special thanks to the following contributors:

Contributing

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.

Getting Started with Development

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes
  4. Run the tests (dotnet test src/Durable.sln)
  5. Commit your changes (git commit -m 'Add some amazing feature')
  6. Push to the branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

Code Style

Please follow the existing code style and conventions outlined in CLAUDE.md.

Running Tests

Tests are written once in src/Test.Shared (Touchstone) and run by three runners.

# xUnit / NUnit adapters (in-memory SQLite by default)
dotnet test src/Test.Xunit/Test.Xunit.csproj
dotnet test src/Test.Nunit/Test.Nunit.csproj

# CLI runner; --docker starts a disposable database container
dotnet run --project src/Test.Automated/Test.Automated.csproj -f net8.0
dotnet run --project src/Test.Automated/Test.Automated.csproj -f net8.0 -- --type postgres --docker
dotnet run --project src/Test.Automated/Test.Automated.csproj -f net8.0 -- --type mysql --docker
dotnet run --project src/Test.Automated/Test.Automated.csproj -f net8.0 -- --type sqlserver --docker

# Use --help for options to target an existing server (--host, --port, --user, --pass, --database)

Engine changes should pass on all four databases, on both net8.0 and net10.0.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (7)

Showing the top 5 NuGet packages that depend on Durable:

Package Downloads
Durable.Sql

Shared SQL engine for Durable ORM providers (dialect abstraction, LINQ-to-SQL translation, materialization, includes). Referenced by Durable.Sqlite, Durable.Postgres, Durable.MySql and Durable.SqlServer.

Durable.Conformance

Conformance kit for Durable ORM backends. Implement IConformanceTarget for your IRepository<T> backend (SQL, document store, search engine, graph store, in-memory) and run the capability-aware Touchstone suites from ConformanceSuites.Build to prove CRUD, querying, includes, grouping, projections, query filters, soft delete, composite keys, value converters, optimistic concurrency, batch writes, upsert, transactions and string matching behave exactly like every other Durable backend. Unsupported capabilities are verified to fail fast with NotSupportedException.

Durable.LiteDb

LiteDB backend for Durable ORM: store entities in an embedded single-file (or in-memory) LiteDB document database with the full IRepository surface, LINQ predicates pushed down to LiteDB indexes where the semantics match C#, exact value round-trips, includes, optimistic concurrency and async-safe transactions.

Durable.InMemory

In-memory backend for Durable ORM: the reference IRepositoryBackend implementation with snapshot-isolation transactions, value converters, JSON columns, includes and full LINQ query evaluation. Ideal for tests and prototyping.

Durable.LiteGraph

LiteGraph backend for Durable ORM (depends on the LiteGraph package): stores each entity row as a node of a LiteGraph property graph, maintains foreign keys as edges, uses deterministic node GUIDs for key lookups, supports atomic interactive transactions, includes and full LINQ query evaluation, so Durable data is first-class for LiteGraph traversals, algorithms and tools.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.7.1 201 10/7/2026
0.7.0 196 10/7/2026
0.6.0 166 10/7/2026
0.5.0 171 10/7/2026
0.4.0 157 10/5/2026
0.3.0 127 10/5/2026
0.2.0 165 8/16/2026
0.1.21 866 11/25/2025
0.1.20 289 11/25/2025
0.1.19 258 11/25/2025
0.1.18 308 11/24/2025
0.1.17 297 11/24/2025
0.1.16 296 11/24/2025
0.1.15 802 11/18/2025
0.1.12 593 11/7/2025
0.1.11 305 11/2/2025
0.1.10 475 10/28/2025
0.1.9 244 10/22/2025
0.1.7 268 10/21/2025
0.1.2 303 10/14/2025
Loading failed

0.3.0: backend-neutral core (cached entity metadata, composite keys, value converters, conventions, global query filters, soft delete, diagnostics); SQL engine moved to Durable.Sql.