Skip to content

Serenity 10.6.0 Release Notes (2026-10-11)

These release notes detail the significant changes made in Serenity and StartSharp from version 10.5.2 to 10.6.0. For a complete list of changes, please refer to the Serenity Change Log.

This release has three big themes: security by default (permission checks now fail closed and sensitive fields are protected automatically), list request control (capabilities, page size limits and a "More" indicator that avoids COUNT(*)), and query building improvements (projections, row-aware From/JoinVia, query freezing, LIKE escaping and per-field comparers). It also adds optimistic concurrency, customizable user entity options, and replaces the ImageSharp based image processing with a pluggable IImageProcessor.

Permission Checks Now Fail Closed (Breaking Change)

This is the most important breaking change in 10.6.0. Until 10.5.2, permission keys were optional: a row without any permission attributes (or with an empty key) was effectively public, and several request handlers and scripts silently fell back to a "?" (logged in) check. Forgetting a permission attribute therefore allowed access instead of denying it.

Starting with 10.6.0, permission checks fail closed:

  • PermissionAttributeBase (and its subclasses like ReadPermissionAttribute, ModifyPermissionAttribute, PageAuthorizeAttribute, etc.) now rejects null, empty or whitespace permission keys with an ArgumentException, and exposes a non-nullable Permission string.
  • Request handlers (list, retrieve, save, delete, undelete), RowFieldsBase, ReportRegistry and lookup/dynamic scripts use SpecialPermissionKeys.Deny instead of "?" / empty fallbacks, so a missing permission attribute denies access rather than allowing it.

A row that used to be implicitly public now needs an explicit permission. The special permission keys are defined in SpecialPermissionKeys:

Permission key Meaning
SpecialPermissionKeys.Public ("*") Anyone, including anonymous users
SpecialPermissionKeys.LoggedIn ("?") Any authenticated user
SpecialPermissionKeys.Deny ("DENY") Nobody, even super admins (never granted)
// 10.5.2 — no permission attributes meant the row was effectively public
public sealed class SomeRow : Row<SomeRow.RowFields>, IIdRow, INameRow
{
}

// 10.6.0 — equivalent explicit declaration
[ReadPermission(SpecialPermissionKeys.Public)]
[ModifyPermission(SpecialPermissionKeys.Public)]
public sealed class SomeRow : Row<SomeRow.RowFields>, IIdRow, INameRow
{
}

Other related changes in the same area:

  • A new PermissionAttributeBase.Join helper makes it easier to build module path permission keys (Join("Administration", "Security") -> "Administration:Security").
  • The DENY permission is never granted, not even to super admins.
  • Denied users now receive EntityNotFound instead of AccessDenied for delete, undelete and save requests, so a caller cannot distinguish a missing record from a forbidden one (create requests keep AccessDenied, since a create cannot leak existence).
  • The framework's own sample and test rows that were implicitly public were annotated explicitly (e.g. UserPreferenceRow is now marked with LoggedIn).
  • MockPermissions used in tests now denies empty and DENY keys as well.

Upgrading: Audit your rows for missing permission attributes. Apps that were relying on the implicit public behavior must now add [ReadPermission(...)] / [ModifyPermission(...)] attributes, or SpecialPermissionKeys.LoggedIn / Public where appropriate.

Sensitive Fields Are Protected by Default

A forgotten [MinSelectLevel(SelectLevel.Never)], [Insertable(false)] or [Updatable(false)] annotation could previously leak secrets (password hashes, tokens, API keys) in retrieve/list responses, or allow clients to mass assign them.

RowFieldsBase now detects fields whose property or column names look sensitive (password hash/salt, secrets, API keys, tokens, 2FA data, etc.) and synthesizes the following attributes automatically, only when the matching attribute was not set explicitly, so you can still override them:

  • MinSelectLevel(SelectLevel.Never) — the field is never selected
  • Sortable(false)
  • Filterable(false)
  • For mapped (table) columns only: Insertable(false) and Updatable(false)

The synthesized attributes are appended to the field's CustomAttributes, so the behavior is visible in metadata. NotMapped sensitive fields (e.g. password input properties) stay insertable and updatable, as they never reach the database.

Fields implementing IPasswordRow (hash/salt) are denied by interface too, regardless of their names, unless a MinSelectLevel annotation opts them back in. List, retrieve, filter and save handlers all honor this.

New FilterableAttribute / NotFilterableAttribute give control over filtering separately from selection:

public sealed class MyUserRow : Row<MyUserRow.RowFields>, IPasswordRow
{
    // arbitrary name — still protected via IPasswordRow, unless opted back in
    [DisplayName("Password hash"), Size(100)]
    public string? Password { get => fields.Password[this]; set => fields.Password[this] = value; }

    // sensitive by name — MinSelectLevel(Never), Sortable(false), Filterable(false),
    // Insertable(false), Updatable(false) synthesized automatically
    [DisplayName("API key"), Size(100)]
    public string? ApiKey { get => fields.ApiKey[this]; set => fields.ApiKey[this] = value; }

    // opt back in explicitly if it is not actually sensitive
    [DisplayName("Access token"), Size(100), Filterable(true), MinSelectLevel(SelectLevel.Details)]
    public string? AccessToken { get => fields.AccessToken[this]; set => fields.AccessToken[this] = value; }
}

[Filterable(true)] clears the deny-filtering flag, [Filterable(false)] sets it. NotFilterableAttribute no longer takes a value and always reports Value == false.

Upgrading: If a field with a sensitive-looking name is intentionally selectable, sortable, filterable or writable, opt it back in with the corresponding explicit attribute.

Undelete Permission

Undeletion can now be authorized separately from deletion. The row definition (and thus the generated client metadata) carries an optional undeletePermission originating from the [UndeletePermission] attribute:

export abstract class UserRow {
    static readonly deletePermission = 'Administration:Security';
    static readonly undeletePermission = 'Administration:Security'; // new
    static readonly updatePermission = 'Administration:Security';
    // ...
}

Client-side, EntityGrid and EntityDialog gained getUndeletePermission() and hasUndeletePermission() helpers:

  • The dialog's Undelete button now checks the undelete permission instead of the delete permission.
  • The grid's include-deleted toggle is only created when the user may undelete (createIncludeDeletedButton override).

If a row does not define an explicit undelete permission, the existing fallback chain applies (delete, then modify, then read permission), so most applications are unaffected. The generated undeletePermission metadata is emitted for rows with an active or deleted (soft-delete) property, matching the conditions where an include-deleted toggle / undelete button exists.

Optimistic Concurrency Support

Serenity now has first class support for optimistic concurrency via a version / rowversion field, modeled after the tracked concurrency tokens in frameworks like EF Core and NHibernate. A row implements IConcurrencyVersionRow and exposes its version field:

public sealed class ProductRow : Row<ProductRow.RowFields>, IIdRow, IConcurrencyVersionRow
{
    // returned to the client, but not editable (no form field)
    [DisplayName("Row Version"), Insertable(false), Updatable(false)]
    public int? RowVersion { get => fields.RowVersion[this]; set => fields.RowVersion[this] = value; }

    Field IConcurrencyVersionRow.ConcurrencyVersionField => fields.RowVersion;
}

On save, the save handler appends the version to the update's WHERE clause and raises a localized ConcurrencyConflict validation error when no row is affected. This detects both a stale read (the value sent by the client is used when present) and a concurrent write between the load and the update. Databases that maintain the version themselves (e.g. SQL Server rowversion) need no extra work; for application managed numeric versions, AddConcurrencyVersionTrigger / DropConcurrencyVersionTrigger migration helpers generate the trigger SQL for SQL Server, PostgreSQL, MySQL, Oracle, Firebird and SQLite:

// migration
Alter.Table("Products")
    .AddColumn("RowVersion").AsInt32().NotNullable().WithDefaultValue(0)
    .AddConcurrencyVersionTrigger(this, idField: "ProductID");

An IgnoreConcurrencyVersion flag on SaveRequest lets internal services (localization, master/detail and updatable extension behaviors) update rows regardless of their version.

Client-side, the server typings emit a concurrencyVersionProperty on the generated row class, and EntityDialog sends the loaded value back on save even though the read-only version field has no form field. The Northwind demo's ProductRow was converted to use this feature as a reference.

Customizable User Entity and User ID Type (UserEntityOptions) (Breaking Changes)

Until now, LoggingRow's InsertUserId / UpdateUserId fields and the administration Users table assumed an int ID. UserEntityOptions now configures the user row type, the user table and ID column names, and the ID field type, supporting int, long, Guid and string user IDs:

services.Configure<UserEntityOptions>(options =>
{
    options.RowType = typeof(Administration.UserRow); // default
    options.TableName = "Users";        // default, from the row's [TableName]
    options.IdColumnName = "UserId";    // default, from [IdProperty] / [Column]
    options.IdFieldType = typeof(Int32Field); // default
});

Configure the identifier type before running the initial database migrations. Changing it after the database is created requires a custom migration and corresponding application changes.

  • New [UserIdFieldType] and [UserIdJoinKey] attributes declare fields whose type should follow the configured user ID type (e.g. UserId in log rows):
[DisplayName("User Id"), UserIdFieldType, UserIdJoinKey, LeftJoin(jUser), TextualField(nameof(Username))]
public partial object? UserId { get; set; }
  • New migration helpers AsUserIdType(...) and UserIdForeignKey(...) define columns matching the configured options.
  • The Pro.Coder row fields source generator handles these fields, so feature projects no longer hardcode an int user ID dependency.

Breaking changes:

  • LoggingRow's InsertUserId and UpdateUserId properties are now object (instead of int) and their fields are Field (instead of Int32Field); the actual type is determined by UserEntityOptions. Generated client typings now show InsertUserId?: any.
  • RowFieldsBase.Initialize now expects a third UserEntityOptions argument. This is only a breaking change if you have a custom RowFieldsProvider.
  • Serene's local copy of LoggingRow was removed in favor of the shared Extensions version (they were identical).

List Requests: Capabilities, More Without Counting, and Page Size Limits

List Request Capabilities

A new ListRequestCapabilities flags enum lets list endpoints (and handlers) declare which ListRequest operations they actually support:

Capability Meaning
ContainsText / ContainsField Applies quick search text / field
EqualityFilter, Criteria Applies equality filters / criteria
Sort Applies sorting
Skip, Take, ExcludeTotalCount, IncludeMore Paging related options
IncludeDeleted Applies soft-delete inclusion
ColumnSelection, IncludeColumns, ExcludeColumns Column options
DistinctFields, ExportColumns, Localize Other options
FilteringOptions, PagingOptions Combined sets
Baseline The fixed standard set (will not grow in future releases)
All Currently same as Baseline (may grow in the future)
SourceAdapter What the in-memory source adapter (see below) supports by default

Endpoint actions can declare their supported capabilities with the new [ListRequestCapabilities] attribute, optionally excluding specific options:

public class MyEndpoint : ServiceEndpoint
{
    [ListRequestCapabilities(ListRequestCapabilities.Baseline,
        Exclude = ListRequestCapabilities.ExportColumns)]
    public Task<ListResponse<MyRow>> List(IDbConnection connection, ListRequest request,
        [FromServices] IMyListHandler handler, CancellationToken cancellationToken = default)
    {
        return handler.ListAsync(connection, request, cancellationToken);
    }
}

ServiceLookupEditor uses these capabilities (see below) to know which operations to run locally when the target endpoint does not support them.

More Without Counting (IncludeMore / More)

Paged lists usually ran a COUNT(*) just to fill TotalCount. Setting IncludeMore on a list request makes the handler read one extra sentinel row and report ListResponse.More instead, without needing the total count:

// request
{ "skip": 0, "take": 50, "includeMore": true, "excludeTotalCount": true }
// response
{ "entities": [ /* ... */ ], "more": true }

More is computed from the rows actually read (before entity post-processing), so it stays correct even when a processed entity is dropped.

Server-Side Page Size Limits

New RequestHandlerSettings (bound from the RequestHandlerSettings configuration section) applies default and maximum page sizes to every list request:

{
  "RequestHandlerSettings": {
    "DefaultPageSize": 100,
    "MaxPageSize": 1000,
    "ExcludeTotalCountByDefault": false
  }
}
  • DefaultPageSize applies when a request does not specify a take (take is zero). Zero (the default) means no default, so no behavior change unless you opt in.
  • MaxPageSize clamps larger requests. Zero (the default) means no limit.
  • Negative Skip / Take values are now rejected as validation errors (and negative settings values are ignored / treated as zero).
  • The response reports the applied take in ListResponse.Take, and the client RemoteView tracks the applied take in actualRowsPerPage so paging stays correct when the server clamps the page size (rowsPerPage keeps reporting the requested value).

Server side callers that need all rows (exports, reports, background jobs, nested relation loads) should opt out explicitly, otherwise enabling the limits may silently truncate their results:

var request = new ListRequest
{
    Skip = 0,
    Take = 0
}.SuppressPagingLimits(); // ignore DefaultPageSize / MaxPageSize for this request

The suppression flag is internal and is never bound from JSON (clients cannot bypass the limits). Some built-in server side list operations (e.g. master/detail and linking set relation loads) call this automatically.

Warning: Do not enable DefaultPageSize / MaxPageSize without auditing existing list usages, and avoid low values — grids without a pager will be truncated with no way to reach the remaining rows.

Excluding the Total Count by Default

RequestHandlerSettings.ExcludeTotalCountByDefault lets you skip the COUNT(*) for paged requests that do not explicitly ask for a total. ListRequest.ExcludeTotalCount became nullable so an unspecified value (null) is distinct from an explicit false:

  • Callers that send ExcludeTotalCount = false (like the standard grids, which need TotalCount for the pager) are unaffected.
  • A take that the limits turned from zero into an actual page size still returns the total unless IncludeMore was requested, since the caller did not ask to page.

ServiceLookupEditor Gets Smarter

The service lookup editor was reworked to work with services that are not standard Serenity list handlers:

  • It can infer the lookup endpoint and the service URL from the row's metadata / endpoint type, without explicitly specifying service or endpointType in many cases.
  • It infers ID and text fields for both row and non-row (POCO) item types.
  • It supports primitive services returning List<string> and other array responses.
  • The capabilities editor option now takes the ListRequestCapabilities flags. When omitted, all standard properties are sent; when set, the editor infers capabilities from the endpoint actions when possible, and falls back to client-side processing for unsupported operations.
const editor = new ServiceLookupEditor({
    service: "Northwind/Product",
    // run ContainsText locally if the endpoint does not support it
    capabilities: ListRequestCapabilities.Baseline & ~ListRequestCapabilities.ContainsText
});

Client-side processing covers ContainsText (with optional quickSearchFields), equality filtering, criteria, sorting and paging, and is used automatically when the endpoint lacks the matching capability.

In-Memory List Request Source Adapter (StartSharp / Pro.Extensions)

A new infrastructure class in Serenity.Pro.Extensions adapts an arbitrary data source with partial list support to a full ListRequest contract. It prepares a per-request plan, applies the operations the source does not declare as capabilities locally (contains text, equality filters, criteria, sorting, paging, distinct fields, soft-delete exclusion), strips them from the request sent to the source, and post-processes the response:

services.AddInMemoryServiceAdapters();
services.Configure<InMemoryServiceAdapterOptions>(options =>
{
    options.MaxLocalItems = 10_000;  // cap for local processing
    options.SearchIgnoreCase = true; // local LIKE semantics
});

// usage
var prepared = adapter.Prepare(new ListRequestSourceAdapterOptions<MyItem>(
    sourceCapabilities: ListRequestCapabilities.SourceAdapter & ~ListRequestCapabilities.Sort,
    maxLocalItems: 10_000));

var plan = prepared.Plan(request);            // plan.SourceRequest goes to the source
var response = plan.Process(sourceResponse);  // local ops applied, More / TotalCount computed

Details:

  • Requests / responses are memberwise cloned so plan state stays isolated from callers and concrete types are preserved.
  • Criteria are translated to in-memory predicates using SQL null semantics and culture-aware, optionally case-insensitive LIKE matching.
  • Field read permissions are enforced by clearing denied fields on rows and POCOs (can be ignored per source).
  • Paging limits (DefaultPageSize / MaxPageSize) and ExcludeTotalCountByDefault apply to the local list as well; suppression propagates to the inner request and raises the local item cap.
  • A configurable item limit (MaxLocalItems) protects against accidentally materializing huge sources for local processing.

SQL Query Building

ListProjected / QueryProjected and Sql.Expr

New projection helpers map SQL queries to flat typed results (anonymous or named types) without defining Row classes:

var result = new SqlQuery()
    .From(ProductRow.Fields, out var p)
    .Where(p.UnitPrice > 10)
    .ListProjected(connection,
        (ProductRow source) => new
        {
            source.ProductName,
            Upper = Sql.Expr<string?>("UPPER(T0.[ProductName])")
        });
  • ListProjected / QueryProjected (and their async variants) support up to several row sources, constructor projections and object initializers built from direct row field accesses. The source query must not have existing SELECT columns; the projection builds them.
  • Sql.Expr<T> maps arbitrary SQL expressions in projections.
  • Sources are inferred from the query's row fields when each selector parameter has one unambiguous match, or can be supplied explicitly.
  • AsReusableProjected prepares a projection once and executes it multiple times with per-call parameter overrides (IProjectedQuery<TResult>), improving performance for repeated queries.
  • IntoForeignRow populates a RowField via its FK property marked with [ForeignRow(nameof(FKProp))].

Row-Aware From, SubQueryFrom, WithSelf and JoinVia

EntitySqlQueryExtensions gained row-aware From overloads that set the query's INTO row, assign aliases and return the aliased fields via out arguments, plus extension methods to join via a foreign key definition:

var q = new SqlQuery()
    .From(EmployeeRow.Fields, out var e)                     // sets the INTO row, alias T0
    .LeftJoinVia(EmployeeRow.Fields, e.ReportsTo, out var boss) // self join via the FK field
    .Select(e.FirstName)
    .Where(boss.LastName.StartsWith("A"));
  • SubQueryFrom works with any QueryWithParams (e.g. SqlDelete / SqlInsert / SqlUpdate), not just SqlQuery.
  • LeftJoinVia / RightJoinVia / InnerJoinVia build the ON criteria from the FK field's metadata (ForeignKey / ForeignJoinAlias), validating that the target fields type matches.
  • Fluent LeftJoin / RightJoin / InnerJoin row overloads return the auto assigned alias via out arguments.
  • WithSelf(out var reference) gets a reference to the query itself, useful for subqueries.
  • A row-based From sets the query's INTO target and can be called only once per query. For additional sources, use From(fields.As(alias)).Into(row).

Caching and Freezing Queries (Breaking Change)

Fluent SQL builders now cache the SQL generated by ToString(), and a new Freeze() method prepares a query once, freezes it and its parameters, and prevents further modification — ideal for reusable queries:

var query = new SqlQuery()
    .From(MyRow.Fields)
    .Where(MyRow.Fields.Status == 1);
query.Freeze(); // ToString() cached, further modifications throw

// reuse many times
var rows = query.List(connection);

FreezeParams() can be used to freeze only the parameters (so AddParam / SetParam can no longer change them). Freeze and FreezeParams cannot be called from sub queries. Projection methods accept a takeOwnership argument so they can prepare and freeze the source query in place instead of cloning it.

Breaking changes:

  • QueryWithParams.Params is now an IReadOnlyDictionary<string, object?> instead of a mutable dictionary. SQL helper methods accepting a parameter dictionary were converted to IReadOnlyDictionary for consistency.
  • SQL and row interceptors now receive an InterceptXMethodArgs record instead of individual arguments (future extensibility). IRowOperationInterceptor.FindRow / ListRow callbacks receive the built query instead of separate criteria / editQuery arguments (only breaking if you implemented the interceptor, mostly relevant in tests).

Where(ICriteria) on Filterable Queries (Breaking Change)

IFilterableQuery (implemented by SqlQuery, SqlUpdate, SqlDelete) now stores WHERE criteria instead of raw strings, and renders parameterized conditions when Where is called:

public interface IFilterableQuery : IQueryWithParams
{
    void Where(ICriteria? criteria);
    IReadOnlyList<ICriteria> GetWhereCriteria();
    string GetWhereClause();
}
  • Where now accepts ICriteria? instead of a string. Custom implementations must provide GetWhereCriteria and GetWhereClause.
  • SqlUpdate.GetWhereClause is interface-only now, and the legacy params string[] Where helper was removed. Use the string Where extension for raw filters (.Where("UnitPrice > 100")), or chain multiple Where calls.
  • The legacy IChainable interface and its With extension were removed; use WithSelf(out var query) when you need a reference to the query itself.
  • WhereEqual gained an emitIsNull option to emit field IS NULL when the value is null instead of field == null.
  • Reading the WHERE clause repeatedly does not add duplicate parameters; Criteria.Exists(subCriteria) got an overload accepting a BaseCriteria.
  • ValueCriteria snapshots the passed values in its constructor, so a criteria can no longer be changed after creation.

Other Query Improvements

  • WithTableHint and general table hint support (SQL Server only), e.g. query.WithTableHint("READPAST").
  • SQL query expression lookups by alias or column name are now case-insensitive (SqlQuery.GetExpression returned null for a differently cased alias or column before), and the == criteria overloads let you write q.Where(f.Status == 1) instead of new Criteria(f.Status) == 1.
  • Per-dialect IN list parameter budgets: IN queries now reserve 500 parameters below the provider limit (SQL Server 1,600, SQLite 32,266, PostgreSQL/MySQL/Oracle 65,035, Firebird 32,267) and inline the parameters when that threshold is passed. Oracle IN lists with more than 1,000 items always inline.
  • UPSERT fallback: when all fields of an insert are keys, ExecuteUpsert falls back to a correct INSERT/UPDATE combination instead of producing an UPDATE without SET fields.
  • SqlDebugDumper improvements: dialect-aware boolean and GUID literals (PostgreSQL/Firebird TRUE/FALSE, Oracle RAW(16), Firebird UUID literals), Oracle-style :name parameter recognition, and case-insensitive parameter matching (while preserving :: casts and @@ variables).
  • SqlInsert.Clone / SqlDelete.Clone / SqlUpdate.Clone fixes, better frozen query guards, and ExecuteAndGetID returning null instead of crashing on DBNull identity values for UseReturningIdentity.

Escaping in LIKE Criteria and UPPER() Function Criteria

Criteria.StartsWith, EndsWith and Contains now escape the passed text by default using ! as the escape character, so wildcards in user input (%, _) no longer change the meaning of the condition — a long standing correctness issue (and a subtle LIKE injection vector). Like / NotLike keep accepting raw patterns as before:

// 10.6.0 — '%' and '_' in the user input are literal now
q.Where(f.Name.Contains(userInput));

// raw pattern (unchanged behavior)
q.Where(f.Name.Like(userInput + "%"));

// explicit escape character (defaults to '!')
q.Where(f.Name.LikeEscaped(pattern, escape: '\\'));

Client-side Criteria.parse supports the ESCAPE syntax as well, and the JSON serialization uses a fourth array member for the escape character when present (binary criteria without an escape character keep serializing as the old three-element array for compatibility). LikeEscaped / NotLikeEscaped allow specifying the escape character explicitly.

Additionally, UPPER() function criteria (FunctionCallCriteria) are now supported for case-insensitive LIKE queries. The client can produce them (e.g. via Criteria helpers), and the server renders them. Function arguments are validated and copied so they cannot be modified by the caller afterwards.

Per-Field and Per-Connection Comparers

String values were previously always compared with the CurrentCulture by default, so change detection, unique checks and local (in-memory) operations could not honor a database's collation (e.g. case-insensitive or Turkish culture).

  • New ComparerAttribute (row- or field-level) and ComparerResolver parse specifications like "OrdinalIgnoreCase", "tr-TR" or "tr-TR:IgnoreCase".
  • ISqlDialect.WithComparer lets a dialect carry a per-connection comparer, and ConnectionStringEntry.Comparer sets it per connection string.
  • The effective comparer is resolved per field in this order: field attribute, row attribute, dialect comparer, then SqlSettings.DefaultComparer, and baked into each field.
  • SqlSettings.SetLocalComparer provides an AsyncLocal override for tests and background work.
  • ByteArrayField, ListField, JsonField, VariantField and the generic field types fall back to the field comparer instead of the type default.

Int64 Support

JavaScript numbers lose precision above 2^53, which made 64-bit integer values unsafe in grids and dialogs. 10.6.0 adds first class Int64 support:

  • Int64Editor, preserving values beyond Number.MAX_SAFE_INTEGER as bigint or strings, so they are not rounded or truncated.
  • Int64Filtering, so Int64Field columns get the usual comparison operators.
  • BigIntFormatter, formatting unsafe integers straight from a bigint and delegating safe values to NumberFormatter (which now exposes formatValue for reuse without HTML encoding).
  • An int64 validation rule whose min/max/range comparisons use bigint bounds exactly, instead of coercing to Number.
  • Server side, Int64Field serializes values outside the JS safe integer range (2^53) as JSON strings so large IDs keep full precision; the STJ / Newtonsoft row converters read numbers as Int64 when they fit.

Use it for large IDs or numeric columns, e.g. with [Int64Editor, Int64Filtering], and the value stays exact end-to-end.

Image Processing: ImageSharp Removed (Breaking Change)

The SixLabors.ImageSharp dependency was removed from Serenity.Net.Services, because the 2.1.13 package was marked vulnerable and no longer maintained, and newer versions have licensing issues.

  • All upload methods that involve checking or resizing images now require an IImageProcessor registration. Without one, a ThrowingImageProcessor throws a descriptive error explaining how to register your own.
  • The legacy ThumbnailGenerator / UploadProcessor classes are removed.
  • StartSharp and Serene now provide a SkiaSharpImageProcessor backed by SkiaSharp, registered as a singleton before AddUploadStorage():
// Startup.cs (StartSharp / Serene sample)
services.AddSingleton<IImageProcessor, SkiaSharpImageProcessor>();
services.AddUploadStorage(...);

The SkiaSharp implementation covers image size detection, loading with format / extension detection, all scaling modes (stretch, preserve ratio with and without fill, crop) and encoding to JPEG, PNG, GIF and WebP. In-place scaling is optional in the IImageProcessor contract and is not supported by the SkiaSharp implementation.

Upgrading: If your application uses [ImageUploadEditor], [MultipleImageUploadEditor] or any upload path with image checks / resizing, register an IImageProcessor implementation (you can copy the SkiaSharpImageProcessor from the StartSharp or Serene samples).

Localized SQL Constraint Violations

Database constraint violations (unique, foreign key, not null, primary key) are now converted to localized validation errors instead of leaking provider exceptions as server errors, for SQL Server, PostgreSQL, MySQL, Oracle, Firebird and SQLite:

  • New ISqlErrorExtractor / DefaultSqlErrorExtractor recognize the violations from error numbers, SQL states and messages — without taking a direct dependency on any provider package.
  • New HumanizeSqlExceptionBehavior, an implicit save/delete/undelete behavior, converts recognized violations into ValidationErrors (SqlErrorValidationTexts in services.texts.*, plus generic messages when table/column names are unknown).
  • The shared unique pre-check logic moved into BaseUniqueConstraintSaveBehavior with virtual query building, so applications can scope or replace it. [Unique] and [UniqueConstraint] gained a DisableDefaultBehavior option to opt out.
  • Serene and StartSharp's own HumanizeSqlExceptionBehavior, SqlExceptionHelper and SqlExceptionHelperTexts were removed, as the framework supersedes them.

Display Order Values Are Normalized

DisplayOrderHelper.ReorderValues (and its async overloads) now renumber the whole group to consecutive values starting from 1 by default, closing gaps left by removed records:

  • With preserveGaps: true, only the records between the old and new positions are shifted and existing values (including gaps) are reused, updating fewer rows.
  • When no record matches the given id (changingIndex < 0, e.g. the sentinel passed after a delete), the group is renumbered so the gap left by the removed row is closed instead of ignored.
  • Reorders of fields with a unique display order constraint use per-record updates so the renumbering cannot violate the unique constraint mid-update, and display order queries now quote identifiers.

Logging Rows Use UTC Timestamps

Logging rows previously wrote InsertDate / UpdateDate with the server's local clock, so audit trails mixed local and UTC semantics depending on where the server ran. In 10.6.0:

  • LoggingRow's InsertDate / UpdateDate fields are annotated with [DateTimeKind(DateTimeKind.Utc)].
  • Capture log ValidFrom / ValidUntil values are stored in UTC by default; if the log row's fields are explicitly annotated with [DateTimeKind(DateTimeKind.Local)], local time is kept for backward compatibility.
  • The active-until sentinel (9999-01-01) now carries the matching UTC kind, so stored values and close-active comparisons use the same clock. The Northwind ProductLogRow was annotated as Utc as a reference.

DomWise Enhancements

The @serenity-is/domwise JSX library got several additions and lifecycle improvements:

  • A new Show component for declarative conditional rendering, including signal-aware reactive switching and a fallback branch:
import { Show, signal } from "@serenity-is/domwise";

const loading = signal(true);

<Show when={loading} fallback={<div>Content loaded</div>}>
    <div class="spinner">Loading...</div>
</Show>
  • Function children get the when value passed in, so {(user) => <UserProfile user={user} />} works as expected.
  • Signal values are now handled for arrays / iterables (e.g. a Set) and for <select multiple> values.
  • Replacing a whole style or class value clears previous signal-based assignments, so stale assignments no longer linger.
  • Event handlers assigned from signals are cleared when the signal becomes null, and repeated handler updates no longer accumulate disposing listeners.
  • Auto-disposal: the hidden branch element is disposed when the shown element is disposed, and withLifecycleRoot allows scoping signal subscriptions to an element so they are disposed with it.
  • defaultProps are now applied when a prop is explicitly undefined, matching React behavior, and a null reference issue when dataset / on / onCapture are initially assigned null was fixed.

Connection and Module Attributes (Breaking Change)

Feature specific connection and module keys are now declared with dedicated attributes that carry the key as a constant, instead of magic strings or ConnectionKeys classes:

[assembly: ConnectionKeyFallback(MeetingConnectionAttribute.Key, ProFeaturesConnectionAttribute.Key)]

public class MeetingConnectionAttribute() : ConnectionKeyAttribute(Key)
{
    public const string Key = "ProMeeting";
}

// usage (previously [ConnectionKey("ProMeeting")])
[MeetingConnection]
public class MeetingRow : Row<MeetingRow.RowFields> { }
  • New attributes include DefaultConnectionAttribute ("Default"), NorthwindConnectionAttribute / NorthwindModuleAttribute, AdministrationModuleAttribute, and per-feature connection attributes for Meeting, WorkLog, OpenIddict, DataAuditLog, EmailQueue, SqlFileSystem, SqlDistributedCache, SqlDataProtection and Pro.Extensions.
  • The attribute key extraction also works for derived attributes (e.g. a ModuleAttribute subclass).
  • Breaking change (StartSharp): The recently introduced ConnectionKeys classes (e.g. MeetingConnectionKeys) are removed. If you referenced them, use the corresponding attribute's Key constant instead, e.g. MeetingConnectionAttribute.Key.

DeltaLister Is Strict by Default (Breaking Change)

DeltaLister now defaults to strict mode:

  • A new item id that is not present in the old list throws an ArgumentOutOfRangeException instead of being silently treated as an insert. Use DeltaOptions.IgnoreInvalidNewId to keep the old tolerant behavior.
  • Duplicate ids in the old list throw an ArgumentException, and duplicate ids in the new list throw an ArgumentOutOfRangeException.
  • Item ids are cached (each item's id is read only once) for better performance.

Serenity.Pro.Dashboards (Work in Progress)

A new Serenity.Pro.Dashboards module was added under the enterprise features, containing the initial groundwork for dynamic dashboards:

  • Data source abstractions with IDataSource, IDataSourceFactory, IDataSourceTypeRegistry and built-in SqlDataSource, TableDataSource, ViewDataSource and JsonDataSource implementations, plus parameter extraction (IExtractParameters) and aggregate support (IAggregateSource).
  • A Datasets module (connections and datasets) with UI, repository, request handlers, permissions and migrations, built on a SQL metadata module (SqlMetadata).
  • Dashboard, Widget and WidgetCatalog skeleton modules.

Note that this is a work in progress for the Enterprise edition and will not be released yet. It was added under the Pro Features navigation section in StartSharp during development.

Performance Improvements

  • The row factory is now compiled with expression trees instead of Activator.CreateInstance, removing reflection cost from every row materialization.
  • Type source scans and behavior instance factories are cached lazily: DefaultImplicitBehaviorRegistry and DefaultHandlerRegistry scan once and invalidate their cache via change tokens (so dynamic assemblies / feature toggles are still picked up without a restart), and DefaultBehaviorFactory / DefaultBehaviorProvider cache compiled factories and per-call filtered type lists.
  • Relation handlers (master/detail and linking set) previously paged their list results with Skip/Take over a lazy enumerable, re-running the source query once per 1,000-row batch (O(n^2)). The entities are now materialized once and sliced, and the batch size is a constant.
  • Fluent SQL queries cache their ToString() output; combined with Freeze, reused queries no longer rebuild SQL on every execution.

Bugfixes

Serenity:

  • Improved SQL generation and schema metadata handling across supported dialects: provider-specific paging (SQLite / MySQL OFFSET without LIMIT), identity discovery (PostgreSQL 9.x nextval fallback, Oracle before 12c, SQLite WITHOUT ROWID), foreign key queries for PostgreSQL / Firebird / Oracle, MySQL information_schema rewrite, dialect-aware debug dumps, and quoted identifier / comment-aware parameter scanning (SqlRegionScanner).
  • Improved connection and transaction cleanup: WrappedConnection closes/disposes forget dead transactions, Dispose unsubscribes its StateChange handler, commands are disposed with returned readers, profiled connections are disposed when wrapping fails, pool failure retry only applies to the first EnsureOpen plus first query, and TransactionlessUnitOfWork no longer runs rollback callbacks after commit. The original provider exception is preserved as the inner exception when SQL errors are converted to validation errors.
  • Enforced field read permissions for sorting, equality filters, quick search and criteria operands, and applied field permissions to localization values. Denied users now receive EntityNotFound instead of AccessDenied for delete / undelete / save so entity existence is not leaked, and the DENY permission is never granted.
  • Preserved generated server types when source generators are invoked with incomplete design-time compilations (Visual Studio does this during project unloading or external builds), with additional safety checks in MultipleOutputHelper / Pro.Coder and improved Pro.Coder diagnostics and error detection (StartSharp).
  • Honored custom DateFormatString values during JSON conversion and preserved date-only values with custom time-format patterns.
  • Fixed the master-detail save pipeline to delete removed detail rows before updates (previously a unique constraint could fail when a detail row was updated to take a value still held by a removed row), used DeleteDate to detect delete-log rows, returned the entity id for no-op updates, restricted auto save-type detection to identity ids while honoring Request.EntityId, returned a validation error for malformed entity ids instead of a raw conversion exception, and rejected mismatched ids on non-updatable fields.
  • Fixed field conversion and JSON handling: DBNull is projected into non-nullable members as the default, DateOnly / TimeSpan strings are parsed in ConvertValue, DateTimeOffset keeps its offset, base64 length is validated against the field size, stream position is restored after serializing, [Flags] enums reject undefined bits, and VariantField compares same-typed IComparable values directly.
  • Fixed tree ordering for cycles, self-parented rows and deep chains (no more stack overflows, and previously dropped rows are appended); deduped null / duplicate keys in linking-set relations; fixed CaptureLogBehavior marking IIsDeletedRow rows as deleted, LocalizationBehavior assigning the wrong new row id, and UpdatableExtensionBehavior resolving the wrong OtherKeyField. Also fixed null and Guid parent id filters.
  • Fixed SleekGrid issues: top row resolution in getCanvasNode (returned bottom rows), the unused oneOff event option in event utils, the frozen layout not supporting the frozenBottom option (documented), and a race condition in row initialization for parallel calls.
  • Resolved scoped handler / behavior dependencies from the current request scope instead of the root provider (fixed a captive dependency problem where singletons captured the root provider).
  • Quoted identifiers in display-order queries and included the row type in filter validation error messages.
  • Localization: validate that the culture id is non-empty and the value being saved is a row, returning validation errors instead of NullReferenceExceptions.

StartSharp:

  • Pro.Coder's ServerTypingsSourceGenerator now keeps the previous ServerTypes when Visual Studio calls it with an invalid / empty compilation during design-time builds or while closing VS, and emits more tracing to help diagnose failures.
  • DataAuditLogBehavior uses an ordinal comparer for IndexCompare.
  • Updated feature modules and the in-memory list adapter for the new fail-closed permission behavior (AccessDenied -> EntityNotFound changes).

Test Coverage and Tooling

  • Script test coverage was increased across Serene.Web, demo.basicsamples, demo.northwind, Serenity.Extensions, StartSharp.Web, dataexplorer, emailclient, meeting, pro.extensions, worklog and Demo.AdvancedSamples, mostly above 95%.
  • Vitest globals were removed in favor of explicit imports in all test files, and the vmThreads pool is used for faster test execution.
  • Serene and StartSharp adopted central NuGet package management (Directory.Packages.props). Templates are still released without central package management, as before.
  • Sergen now validates module and identifier names: module names must be valid namespaces (optionally containing dots), and dots are converted to / for URL and file path generation.
  • The template build scripts were updated (including a build -> ps1 conversion for StartSharp, a restore step and EnableProjectDataOnBuild=false so C# Dev Kit does not interfere with VSIX builds), and the Serene 10.6.0.0 / StartSharp 10.6.0.0 template packages were built with them.

Package Updates

Notable package updates in this release:

  • NuGet: Microsoft.Extensions.*, Microsoft.Data.Sqlite / SqlClient, SkiaSharp 4.153.1, YamlDotNet 18.1.0, Scriban 7.5.0, Microsoft.CodeAnalysis 5.9.0, plus xUnit / Playwright / Cake tooling updates
  • npm: dompurify 3.4.16, vitest 5.0.1, vite 8.3.0, moment 2.31.0, es-module-lexer 3.0.2, jsdom 30.1.0

Upgrading to 10.6.0

  1. Update NuGet packages to 10.6.0 (StartSharp samples reference Serenity.* 10.6.0).

  2. Permissions fail closed. Rows without explicit permission attributes now deny all operations. Add [ReadPermission(...)] / [ModifyPermission(...)] (or the other permission attributes) to rows that were implicitly public, using SpecialPermissionKeys.Public ("*") or SpecialPermissionKeys.LoggedIn ("?") when appropriate. Permission keys can no longer be null or empty.

  3. Sensitive fields. If a field with a sensitive-looking name (or an IPasswordRow hash/salt field) is intentionally selectable, sortable, filterable, insertable or updatable, opt it back in with explicit attributes such as [MinSelectLevel(...)], [Filterable(true)], [Insertable(true)] / [Updatable(true)].

  4. Image processing. If you use image upload editors or any upload path with image checks / resizing, register an IImageProcessor before AddUploadStorage(). StartSharp and Serene ship a SkiaSharp based implementation you can copy. The legacy ThumbnailGenerator / UploadProcessor classes are removed.

  5. User entity options. LoggingRow's InsertUserId / UpdateUserId properties are now object and their fields are Field; update any casts or typed usages. If you have a custom RowFieldsProvider, pass the new UserEntityOptions argument to RowFieldsBase.Initialize. Configure UserEntityOptions before running the initial migrations, and use AsUserIdType / UserIdForeignKey helpers in custom migrations.

  6. Connection keys (StartSharp). Replace references to the ConnectionKeys classes (e.g. MeetingConnectionKeys) with the corresponding attribute's Key constant (e.g. MeetingConnectionAttribute.Key).

  7. Query builder breaking changes. QueryWithParams.Params is now read-only; interceptor implementations should use the new InterceptXMethodArgs records and the built query; custom IFilterableQuery implementations must provide GetWhereCriteria and GetWhereClause; the legacy IChainable / With API and the params string[] Where helper are removed (chain Where calls or use the string Where extension); a row-based From sets the query's INTO target once per query.

  8. DeltaLister. If you relied on unknown new item ids being treated as inserts, pass DeltaOptions.IgnoreInvalidNewId. Duplicate ids now throw.

  9. In-memory adapter options (StartSharp). Rename Culture / IgnoreCase options to SearchCulture / SearchIgnoreCase.

  10. Regenerate code. Run your normal build / sergen transforms so generated row types pick up concurrencyVersionProperty, undeletePermission and the new user ID field types. Sergen now rejects invalid module names; module names with dots are treated as namespaces.

  11. Consider enabling the new features. Page size limits and ExcludeTotalCountByDefault are opt-in but please read the warnings above and audit server side list usages before enabling. Optimistic concurrency requires a version column (and trigger for application managed numeric versions) on the rows you want to protect.