Serenity 10.6.0 Release Notes

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:

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:

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:

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:

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.

[DisplayName("User Id"), UserIdFieldType, UserIdJoinKey, LeftJoin(jUser), TextualField(nameof(Username))]
public partial object? UserId { get; set; }

Breaking changes:

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

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:

ServiceLookupEditor Gets Smarter

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

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:

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

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

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:

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

Other Query Improvements

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

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:

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.

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

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:

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:

DomWise Enhancements

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

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>

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

DeltaLister Is Strict by Default (Breaking Change)

DeltaLister now defaults to strict mode:

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:

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

Bugfixes

Serenity:

StartSharp:

Test Coverage and Tooling

Package Updates

Notable package updates in this release:

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.