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 likeReadPermissionAttribute,ModifyPermissionAttribute,PageAuthorizeAttribute, etc.) now rejects null, empty or whitespace permission keys with anArgumentException, and exposes a non-nullablePermissionstring.- Request handlers (list, retrieve, save, delete, undelete),
RowFieldsBase,ReportRegistryand lookup/dynamic scripts useSpecialPermissionKeys.Denyinstead 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.Joinhelper makes it easier to build module path permission keys (Join("Administration", "Security")->"Administration:Security"). - The
DENYpermission is never granted, not even to super admins. - Denied users now receive
EntityNotFoundinstead ofAccessDeniedfor delete, undelete and save requests, so a caller cannot distinguish a missing record from a forbidden one (create requests keepAccessDenied, since a create cannot leak existence). - The framework's own sample and test rows that were implicitly public were annotated explicitly (e.g.
UserPreferenceRowis now marked withLoggedIn). MockPermissionsused in tests now denies empty andDENYkeys 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 selectedSortable(false)Filterable(false)- For mapped (table) columns only:
Insertable(false)andUpdatable(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 (
createIncludeDeletedButtonoverride).
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.UserIdin log rows):
[DisplayName("User Id"), UserIdFieldType, UserIdJoinKey, LeftJoin(jUser), TextualField(nameof(Username))]
public partial object? UserId { get; set; }
- New migration helpers
AsUserIdType(...)andUserIdForeignKey(...)define columns matching the configured options. - The Pro.Coder row fields source generator handles these fields, so feature projects no longer hardcode an
intuser ID dependency.
Breaking changes:
LoggingRow'sInsertUserIdandUpdateUserIdproperties are nowobject(instead ofint) and their fields areField(instead ofInt32Field); the actual type is determined byUserEntityOptions. Generated client typings now showInsertUserId?: any.RowFieldsBase.Initializenow expects a thirdUserEntityOptionsargument. This is only a breaking change if you have a customRowFieldsProvider.- Serene's local copy of
LoggingRowwas 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
}
}
DefaultPageSizeapplies 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.MaxPageSizeclamps larger requests. Zero (the default) means no limit.- Negative
Skip/Takevalues 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 clientRemoteViewtracks the applied take inactualRowsPerPageso paging stays correct when the server clamps the page size (rowsPerPagekeeps 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 needTotalCountfor the pager) are unaffected. - A take that the limits turned from zero into an actual page size still returns the total unless
IncludeMorewas 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
serviceorendpointTypein 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
capabilitieseditor option now takes theListRequestCapabilitiesflags. 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) andExcludeTotalCountByDefaultapply 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.
AsReusableProjectedprepares a projection once and executes it multiple times with per-call parameter overrides (IProjectedQuery<TResult>), improving performance for repeated queries.IntoForeignRowpopulates aRowFieldvia 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"));
SubQueryFromworks with anyQueryWithParams(e.g.SqlDelete/SqlInsert/SqlUpdate), not justSqlQuery.LeftJoinVia/RightJoinVia/InnerJoinViabuild the ON criteria from the FK field's metadata (ForeignKey/ForeignJoinAlias), validating that the target fields type matches.- Fluent
LeftJoin/RightJoin/InnerJoinrow overloads return the auto assigned alias viaoutarguments. WithSelf(out var reference)gets a reference to the query itself, useful for subqueries.- A row-based
Fromsets the query'sINTOtarget and can be called only once per query. For additional sources, useFrom(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.Paramsis now anIReadOnlyDictionary<string, object?>instead of a mutable dictionary. SQL helper methods accepting a parameter dictionary were converted toIReadOnlyDictionaryfor consistency.- SQL and row interceptors now receive an
InterceptXMethodArgsrecord instead of individual arguments (future extensibility).IRowOperationInterceptor.FindRow/ListRowcallbacks 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();
}
Wherenow acceptsICriteria?instead of a string. Custom implementations must provideGetWhereCriteriaandGetWhereClause.SqlUpdate.GetWhereClauseis interface-only now, and the legacyparams string[]Wherehelper was removed. Use the stringWhereextension for raw filters (.Where("UnitPrice > 100")), or chain multipleWherecalls.- The legacy
IChainableinterface and itsWithextension were removed; useWithSelf(out var query)when you need a reference to the query itself. WhereEqualgained anemitIsNulloption to emitfield IS NULLwhen the value is null instead offield == null.- Reading the WHERE clause repeatedly does not add duplicate parameters;
Criteria.Exists(subCriteria)got an overload accepting aBaseCriteria. ValueCriteriasnapshots the passed values in its constructor, so a criteria can no longer be changed after creation.
Other Query Improvements
WithTableHintand 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.GetExpressionreturned null for a differently cased alias or column before), and the==criteria overloads let you writeq.Where(f.Status == 1)instead ofnew Criteria(f.Status) == 1. - Per-dialect
INlist parameter budgets:INqueries 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. OracleINlists with more than 1,000 items always inline. - UPSERT fallback: when all fields of an insert are keys,
ExecuteUpsertfalls back to a correct INSERT/UPDATE combination instead of producing an UPDATE without SET fields. SqlDebugDumperimprovements: dialect-aware boolean and GUID literals (PostgreSQL/FirebirdTRUE/FALSE, OracleRAW(16), Firebird UUID literals), Oracle-style:nameparameter recognition, and case-insensitive parameter matching (while preserving::casts and@@variables).SqlInsert.Clone/SqlDelete.Clone/SqlUpdate.Clonefixes, better frozen query guards, andExecuteAndGetIDreturning null instead of crashing onDBNullidentity values forUseReturningIdentity.
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) andComparerResolverparse specifications like"OrdinalIgnoreCase","tr-TR"or"tr-TR:IgnoreCase". ISqlDialect.WithComparerlets a dialect carry a per-connection comparer, andConnectionStringEntry.Comparersets 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.SetLocalComparerprovides anAsyncLocaloverride for tests and background work.ByteArrayField,ListField,JsonField,VariantFieldand 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 beyondNumber.MAX_SAFE_INTEGERasbigintor strings, so they are not rounded or truncated.Int64Filtering, soInt64Fieldcolumns get the usual comparison operators.BigIntFormatter, formatting unsafe integers straight from abigintand delegating safe values toNumberFormatter(which now exposesformatValuefor reuse without HTML encoding).- An
int64validation rule whose min/max/range comparisons usebigintbounds exactly, instead of coercing toNumber. - Server side,
Int64Fieldserializes 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 asInt64when 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
IImageProcessorregistration. Without one, aThrowingImageProcessorthrows a descriptive error explaining how to register your own. - The legacy
ThumbnailGenerator/UploadProcessorclasses are removed. - StartSharp and Serene now provide a
SkiaSharpImageProcessorbacked by SkiaSharp, registered as a singleton beforeAddUploadStorage():
// 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/DefaultSqlErrorExtractorrecognize 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 intoValidationErrors(SqlErrorValidationTextsinservices.texts.*, plus generic messages when table/column names are unknown). - The shared unique pre-check logic moved into
BaseUniqueConstraintSaveBehaviorwith virtual query building, so applications can scope or replace it.[Unique]and[UniqueConstraint]gained aDisableDefaultBehavioroption to opt out. - Serene and StartSharp's own
HumanizeSqlExceptionBehavior,SqlExceptionHelperandSqlExceptionHelperTextswere 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'sInsertDate/UpdateDatefields are annotated with[DateTimeKind(DateTimeKind.Utc)].- Capture log
ValidFrom/ValidUntilvalues 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 NorthwindProductLogRowwas annotated as Utc as a reference.
DomWise Enhancements
The @serenity-is/domwise JSX library got several additions and lifecycle improvements:
- A new
Showcomponent 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
whenvalue 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
styleorclassvalue 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
withLifecycleRootallows scoping signal subscriptions to an element so they are disposed with it. defaultPropsare now applied when a prop is explicitlyundefined, matching React behavior, and a null reference issue whendataset/on/onCaptureare 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
ModuleAttributesubclass). - Breaking change (StartSharp): The recently introduced
ConnectionKeysclasses (e.g.MeetingConnectionKeys) are removed. If you referenced them, use the corresponding attribute'sKeyconstant 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
ArgumentOutOfRangeExceptioninstead of being silently treated as an insert. UseDeltaOptions.IgnoreInvalidNewIdto keep the old tolerant behavior. - Duplicate ids in the old list throw an
ArgumentException, and duplicate ids in the new list throw anArgumentOutOfRangeException. - 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,IDataSourceTypeRegistryand built-inSqlDataSource,TableDataSource,ViewDataSourceandJsonDataSourceimplementations, plus parameter extraction (IExtractParameters) and aggregate support (IAggregateSource). - A
Datasetsmodule (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:
DefaultImplicitBehaviorRegistryandDefaultHandlerRegistryscan once and invalidate their cache via change tokens (so dynamic assemblies / feature toggles are still picked up without a restart), andDefaultBehaviorFactory/DefaultBehaviorProvidercache compiled factories and per-call filtered type lists. - Relation handlers (master/detail and linking set) previously paged their list results with
Skip/Takeover 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 withFreeze, 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
OFFSETwithoutLIMIT), identity discovery (PostgreSQL 9.xnextvalfallback, Oracle before 12c, SQLiteWITHOUT ROWID), foreign key queries for PostgreSQL / Firebird / Oracle, MySQLinformation_schemarewrite, dialect-aware debug dumps, and quoted identifier / comment-aware parameter scanning (SqlRegionScanner). - Improved connection and transaction cleanup:
WrappedConnectioncloses/disposes forget dead transactions,Disposeunsubscribes its StateChange handler, commands are disposed with returned readers, profiled connections are disposed when wrapping fails, pool failure retry only applies to the firstEnsureOpenplus first query, andTransactionlessUnitOfWorkno 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
EntityNotFoundinstead ofAccessDeniedfor delete / undelete / save so entity existence is not leaked, and theDENYpermission 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
DateFormatStringvalues 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
DeleteDateto detect delete-log rows, returned the entity id for no-op updates, restricted auto save-type detection to identity ids while honoringRequest.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:
DBNullis projected into non-nullable members as the default,DateOnly/TimeSpanstrings are parsed inConvertValue,DateTimeOffsetkeeps its offset, base64 length is validated against the field size, stream position is restored after serializing,[Flags]enums reject undefined bits, andVariantFieldcompares same-typedIComparablevalues 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
CaptureLogBehaviormarkingIIsDeletedRowrows as deleted,LocalizationBehaviorassigning the wrong new row id, andUpdatableExtensionBehaviorresolving the wrongOtherKeyField. Also fixed null andGuidparent id filters. - Fixed SleekGrid issues: top row resolution in
getCanvasNode(returned bottom rows), the unusedoneOffevent option in event utils, the frozen layout not supporting thefrozenBottomoption (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
ServerTypingsSourceGeneratornow keeps the previousServerTypeswhen 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. DataAuditLogBehavioruses an ordinal comparer forIndexCompare.- Updated feature modules and the in-memory list adapter for the new fail-closed permission behavior (
AccessDenied->EntityNotFoundchanges).
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,worklogandDemo.AdvancedSamples, mostly above 95%. - Vitest globals were removed in favor of explicit imports in all test files, and the
vmThreadspool 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->ps1conversion for StartSharp, a restore step andEnableProjectDataOnBuild=falseso C# Dev Kit does not interfere with VSIX builds), and theSerene 10.6.0.0/ StartSharp10.6.0.0template packages were built with them.
Package Updates
Notable package updates in this release:
- NuGet:
Microsoft.Extensions.*,Microsoft.Data.Sqlite/SqlClient,SkiaSharp4.153.1,YamlDotNet18.1.0,Scriban7.5.0,Microsoft.CodeAnalysis5.9.0, plus xUnit / Playwright / Cake tooling updates - npm:
dompurify3.4.16,vitest5.0.1,vite8.3.0,moment2.31.0,es-module-lexer3.0.2,jsdom30.1.0
Upgrading to 10.6.0
Update NuGet packages to 10.6.0 (StartSharp samples reference
Serenity.*10.6.0).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, usingSpecialPermissionKeys.Public("*") orSpecialPermissionKeys.LoggedIn("?") when appropriate. Permission keys can no longer be null or empty.Sensitive fields. If a field with a sensitive-looking name (or an
IPasswordRowhash/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)].Image processing. If you use image upload editors or any upload path with image checks / resizing, register an
IImageProcessorbeforeAddUploadStorage(). StartSharp and Serene ship a SkiaSharp based implementation you can copy. The legacyThumbnailGenerator/UploadProcessorclasses are removed.User entity options.
LoggingRow'sInsertUserId/UpdateUserIdproperties are nowobjectand their fields areField; update any casts or typed usages. If you have a customRowFieldsProvider, pass the newUserEntityOptionsargument toRowFieldsBase.Initialize. ConfigureUserEntityOptionsbefore running the initial migrations, and useAsUserIdType/UserIdForeignKeyhelpers in custom migrations.Connection keys (StartSharp). Replace references to the
ConnectionKeysclasses (e.g.MeetingConnectionKeys) with the corresponding attribute'sKeyconstant (e.g.MeetingConnectionAttribute.Key).Query builder breaking changes.
QueryWithParams.Paramsis now read-only; interceptor implementations should use the newInterceptXMethodArgsrecords and the built query; customIFilterableQueryimplementations must provideGetWhereCriteriaandGetWhereClause; the legacyIChainable/WithAPI and theparams string[]Wherehelper are removed (chainWherecalls or use the stringWhereextension); a row-basedFromsets the query'sINTOtarget once per query.DeltaLister. If you relied on unknown new item ids being treated as inserts, pass
DeltaOptions.IgnoreInvalidNewId. Duplicate ids now throw.In-memory adapter options (StartSharp). Rename
Culture/IgnoreCaseoptions toSearchCulture/SearchIgnoreCase.Regenerate code. Run your normal build / sergen transforms so generated row types pick up
concurrencyVersionProperty,undeletePermissionand the new user ID field types. Sergen now rejects invalid module names; module names with dots are treated as namespaces.Consider enabling the new features. Page size limits and
ExcludeTotalCountByDefaultare 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.