redb releases
Every redb release, newest first. Verbatim from the repository.
3.4.0
Why 3.4.0 (a minor bump), not 3.3.4. This release adds features, not just fixes: explicit scheme names (
[RedbScheme(Name = "...")]), scheme-name validation triggers on MSSql/SQLite, and the newThrowOnSchemeMismatchload-time guard. Under SemVer that is a minor version. The core packages (RedBase.Core, the three providers Free/Pro,RedBase.Export,RedBase.CLI) move together to 3.4.0;redb.Route,redb.Tsakandredb.Identitykeep their own version lines.
Added
Explicit scheme names —
[RedbScheme(Name = "...")](RedBase.Core+ all providers). Until now a scheme was always named after the CLR type'sFullName, and the string in the attribute was only a cosmetic_alias. A scheme name can now be pinned explicitly, which decouples the database identity of a scheme from C# namespace/class refactoring.The positional argument is, and stays, the alias — an explicit name is only settable through the named
Nameparameter, so not one existing declaration changes meaning. Types that declare noNamebehave exactly as before.On the first sync a type with an explicit name has its scheme renamed in the database. The scheme is looked up along a three-step chain — explicit name,
FullName, short type name — and the first match is renamed in place. The rename is a single-rowUPDATEof_schemes._name: the id is preserved, so objects, structures and values are untouched, and polymorphic loading (which resolves throughscheme_id) is unaffected.Renaming requires every consumer of that database to be updated together. An application version that predates the explicit name will not find the scheme under its new name and will create a second one, splitting objects between them silently. If that has already happened, redb now detects it and refuses to continue rather than picking one of the two.
Explicit names must follow C# identifier rules (Latin letters, digits,
_,.,+; no reserved words; 128 characters max) and are validated in C# before any SQL is issued, so the error names the offending type. Human-readable titles belong inAlias, which is free-form.Scheme-name validation in MSSql and SQLite (
RedBase.MSSql,RedBase.SQLite). Both providers now carry a_schemesname-validation trigger mirroring the PostgreSQLvalidate_scheme_name()rule for rule, so a name accepted by one provider is accepted by all three. Applies to newly created databases only —EnsureDatabaseAsyncskips initialisation when_schemesalready exists. The C#-level validation covers databases of any age.RedbServiceConfiguration.ThrowOnSchemeMismatch(RedBase.Core, defaultfalse). Chooses howLoadAsync<TProps>reacts when the object's scheme does not matchTProps(see Fixed):falsereturnsnull(so a soft-deleted object, scheme-10, reads asnull— what soft-delete callers expect);truethrowsRedbSchemeMismatchExceptionto surface a genuine type mistake loudly.
Changed
- The SQLite native extension is renamed
redb→redbsqlite(RedBase.SQLite, Free tier). The package now shipsruntimes/<rid>/native/redbsqlite.{dll,so}(win-x64, linux-x64, linux-arm64) instead ofredb.{dll,so}. The generic name collided with the managedredb.*assemblies and with theredb.*prune globs a host applies to its own bin directory — a native loadable module was being swept up as if it were one of ours. The C init symbol is unchanged (sqlite3_redb_init) and the binaries are byte-for-byte the ones shipped asredb.*in 3.3.3: this is a rename of the file, not a rebuild, andLoadExtensionhas always been called with an explicit entry point.- Nothing to do if you let the package resolve the extension (
SqliteDataSource .LocatePackagedExtension(), the default for the Free DI registration) — it looks for the new name. - Action required if you pin the path yourself —
REDB_SQLITE_EXTENSION, an explicitNativeExtensionPath, a DockerfileCOPY, or a deploy script that copiesredb.soby name: point it atredbsqlite.{dll,so}. A stale path fails at connection open, not at build.
- Nothing to do if you let the package resolve the extension (
Fixed
Concurrent start-up of several nodes could fail to create a scheme (
RedBase.Core+ all providers). Reproduced in production on a three-node cluster. When several instances started against a database that did not yet have a given scheme, all of them missed the lookup and all issuedINSERT INTO _schemes; every instance but one failed with an unhandledUNIQUE(_name)violation during initialisation.Scheme creation now uses a conflict-free statement per dialect (
ON CONFLICT (_name) DO NOTHINGon PostgreSQL and SQLite,INSERT ... WHERE NOT EXISTSwithUPDLOCK, HOLDLOCKon MSSql) and the instance that loses the race reads back the winner's row. Catching the violation instead would not have worked: on PostgreSQL a failed statement inside a transaction poisons it, so the follow-up read would fail with25P02. Both creation paths are covered — typed (EnsureSchemeFromTypeAsync<T>) and untyped (EnsureObjectSchemeAsync).Pro data migrations never ran — on any provider (
RedBase.Core.Pro,RedBase.SQLite,RedBase.MSSql). Four separate defects stacked on top of each other, and the feature had no test coverage at all, so none of them had ever surfaced:MigrationExtensions.CreateExecutorfetched the DB context by reflection asking for a NonPublicContextproperty, butRedbServiceBase.Contextis public — the lookup never matched and every migration died with "Cannot get IRedbContext from RedbService". It now usesredb.Contextand(ISchemeSyncProvider)redbdirectly; both are part ofIRedbService.- The generated UPDATE was hardcoded to PostgreSQL syntax (
UPDATE _values target ...). SQLite forbids aliasing an UPDATE target (near "target": syntax error) and T-SQL needs the alias bound in a trailingFROM. AddedISqlDialectPro.Migration_UpdateTarget(table, alias)with one implementation per provider. - SQLite had no
_migrationshistory table at all: PG/MSSql get it from the concatenatedredb_init.sql, and SQLite has no such concatenation step, so the sharedredb_migrations.sqlnever reached it. A SQLite-native DDL (INTEGER PK, REAL Julian_applied_at, INTEGER_dry_run) now ships inredbSqlite.sql. - MSSql declared
_migrations._idasIDENTITY(1,1)— the only IDENTITY in the whole MSSql schema — while the executor supplies ids explicitly like every other redb table, so writing history failed with "Cannot insert explicit value for identity column". IDENTITY removed.
Covered by a new
MigrationTestsBasesuite (apply / history row / idempotency / dry-run) running against all three Pro fixtures. Note: existing databases do not receive the corrected_migrationsDDL, sinceEnsureDatabaseAsyncskips initialisation when_schemesalready exists — but no database can hold real migration history, because the feature could not complete a single run.A scheme's
_aliaswas never updated after creation (RedBase.Core+ all providers). It was written once onINSERTand then frozen: changing[RedbScheme("...")]on a class left the old value in the database forever. Structure aliases have always synchronised (Structures_UpdateAlias); schemes simply had no equivalent. They do now — the attribute is the source of truth, removing it resets_aliastoNULL, and a value edited by hand in the database is overwritten on the next sync.ClrSchemeTypeIndexpinned hot-reloaded plugin modules in memory (RedBase.Core). The process-globalschemeName → Typeindex held strongTypereferences, and aTypekeeps itsAssemblyLoadContextalive — so a collectible ALC (Tsak hot-swap) could never be collected, and after a reload the stale instance produced a falseRedbSchemeNameConflictExceptionagainst the fresh one, blocking the module from loading. Entries are nowWeakReference<Type>(dead ones pruned on lookup), and two instances of the sameFullNamefrom different ALCs are recognised as a reload, not a name clash. Distinct types sharing one explicitNamestill conflict, by design.LoadAsync<TProps>did not verify the loaded object's scheme (RedBase.Core+ all providers). Loading an object of one scheme under an unrelatedTPropsdeserialised garbage into the Props and — withEnablePropsCache— cached it underobjectId, so a laterGetWithoutHashValidationkept returning the garbage.LoadAsync<TProps>now checks the object's_id_schemeagainst the schemeTPropsmaps to before anything is cached. By default a mismatch returnsnull— so a soft-deleted object (scheme-10, set bySoftDeleteAsync) reads asnull, which is what soft-delete callers expect; setRedbServiceConfiguration.ThrowOnSchemeMismatch = trueto instead throwRedbSchemeMismatchExceptionon a genuine type mistake. Either way garbage never reaches the cache. The untypedLoadAsync(objectId)is never affected.Invalid explicit scheme names failed one at a time (
RedBase.Core). Auto-sync validates every[RedbScheme(Name = "...")]and (by design) refuses to boot on an invalid name — but it stopped at the first offender, so a codebase with several had to be fixed one rerun at a time. Names are now all validated up front and reported together in a singleAggregateException.
Removed
- Dead metadata-cache interface layer (
RedBase.Core).ICompositeMetadataCache,ISchemeMetadataCache,IStructureMetadataCache,ITypeMetadataCache,IStaticMetadataCacheandStaticMetadataCache— 679 lines across five files that referenced only each other. None was ever implemented, none was ever consumed; the live caches areGlobalMetadataCache(per cache domain),GlobalListCache,GlobalPropsCacheandClrSchemeTypeIndex(per process). Formally a breaking change since the types were public, but they could not be used for anything: the interfaces had no implementations.CacheDiagnosticInfo,CacheHealthStatus,MemoryUsageInfoandPerformanceInfolived in the same file and are part of the liveISchemeCacheProvidercontract — they moved unchanged toredb.Core/Caching/CacheDiagnosticInfo.cs.redb.Core/Caching/README.md, which documented only the removed layer, has been rewritten to describe the caches that actually exist.
3.3.3
Why 3.3.3 and not 3.3.1. The number jumps to stay in step with the rest of the ecosystem, which had drifted ahead:
redb.Routeandredb.Tsakwere at 3.3.1, andredb.Route.Sql/redb.Route.Sqsat 3.3.2 (a partial connector release). From 3.3.3 every package ships one number — redb core, redb.Route and redb.Tsak — so "which versions go together" stops being a question. There are no core releases numbered 3.3.1 or 3.3.2; the fix below is the only functional change here.
redb.Identitykeeps its own line (1.2.2) but is released together with this — it depends on redb storage, and without the rebuild its users would stay on the broken init below.
Fixed
- Schema init failed under a non-superuser database owner (
RedBase.Postgres). The embeddedredb_init.sqlcarried a singleALTER FUNCTION migrate_structure_type(...) OWNER TO postgres;(a leftover from a debugging session — the onlyOWNER TOin the whole script).EnsureCreated=trueruns the script as one batch, so on a least-privilege setup (app user owns the database but is not a member of thepostgresrole) the statement failed with "must be able to SET ROLE postgres" and rolled back the entire first-start initialization. The statement is removed: no function in the script isSECURITY DEFINER, so ownership never affected execution, and the function now belongs to the connecting role like every other object — which also keeps futureCREATE OR REPLACEmigrations working. Required app privileges are now justCONNECT+CREATEon the schema + DML. Note:CREATE EXTENSION IF NOT EXISTS pg_trgmstill requires the extension to be preinstalled on PostgreSQL ≤ 12 (on PG 13+pg_trgmis a trusted extension, installable by the database owner).
3.3.0
Added
- Fail-fast concurrency guard on the provider connection (
RedBase.Postgres,RedBase.MSSql,RedBase.SQLite+.Pro). AnIRedbServicewraps a single, non-thread-safe DB connection (EF-DbContext model). If the same instance is entered from two threads at once, each provider now throws a clearInvalidOperationExceptionnaming the cause — instead of an opaque driver error ("A command is already in progress", "connection is busy", "another read operation is already in progress"). LightweightInterlockedcheck with zero cost on the normal single-threaded path; correct scoped usage is never affected.
Fixed
- Query parser:
array.Contains(x)inWhereRedbthrew on .NET 9 / C# 13 (RedBase.Core). Astring[](or any array).Contains(x)inside aWhereRedb(...)predicate now binds to theReadOnlySpanoverload (System.MemoryExtensions.Contains) rather thanEnumerable.Contains, which the filter parser rejected withNotSupportedException. The parser now recognisesMemoryExtensions.Contains, unwraps the array→span conversion, and translates it to the sameINclause asEnumerable.Contains/List.Contains. (Refactored the two-argContainstranslation into a sharedVisitContainsCore.) ComputeHash()NRE on an object withProps == null(RedBase.Core).RedbHash.ComputeForObjectdereferenced the object before a null check, so the genericComputeFor<TProps>path (used byRedbObject<TProps>.ComputeHash()) threwNullReferenceExceptionwhenPropswas null — even though null Props is a supported case (the reflection-basedComputeFor(IRedbObject)already returned null, andComputeForBaseFieldsexists for exactly this). Added the missing guard so the generic path returnsnull(→Guid.Empty) consistently, instead of throwing.- Connection-pool leak on transaction/connection dispose (
RedBase.Postgres,RedBase.MSSql,RedBase.SQLite+.Pro). Disposing the provider connection could skip returning the physical connection to the pool: a throw from the driver's transactionDisposeAsync()(possible mid error-storm on an already-broken connection) bypassed_connectiondisposal. BecauseSaveAsyncruns inside an explicit transaction, every write armed this path, so under a burst of failures the leak was self-amplifying and eventually exhausted the pool (symptom: a healthy pool suddenly climbs pastMaxPoolSizewith connection-timeout errors, cleared only by a restart). The connection'sDisposeAsync/Disposeand the transaction wrapper'sDisposeAsyncnow usetry/finally, so the connection is always returned and the transaction-cleanup callback always runs; the dispose fault is no longer swallowed — it propagates so it stays observable.
3.2.0
Added
- SQLite provider (new):
RedBase.SQLite(Free) +RedBase.SQLite.Pro(Pro). RedBase now runs on SQLite — same LINQ API, same 13-table model, sameAddRedb(...)wiring as Postgres/MSSql, withData Source=app.db. The provider is swappable at the DI line; the rest of the application is unchanged.RedBase.SQLite.Prois pure C# (query SQL built byProSqlBuilder, props materialized in C#, no database-side functions), so it runs anywhereMicrosoft.Data.Sqliteruns — including Blazor WebAssembly and mobile (MAUI / iOS / Android), where a native SQLite extension cannot be loaded. This is the embedded/offline/in-browser tier people asked for.RedBase.SQLite(Free) hosts the in-DB machinery as a native C loadable extension (redb.{dll,so,dylib}) — the SQLite analog of the Postgres/MSSql server-side functions. It is the fullv2-pvtquery compiler (pvt_build_query_sql/_aggregate_/_groupby_/_window_/_projection_/_array_groupby_sql) plus theget_object_jsonmaterializer,save_object_json, soft-delete (mark_for_deletion/purge_trash) and thev_user_permissionsview — ported from ~9k lines of PL/pgSQL to C (sqlite3ext.h). Also callable directly from non-.NET hosts (Python, thesqlite3CLI).- Identity uses a native
AUTOINCREMENTtable; the C extension and the C# key generator advance the samesqlite_sequencehigh-water mark, so ids stay globally unique across .NET and non-.NET callers. - Minimum SQLite 3.44.0+ (
FILTER (WHERE …), window functions,RETURNING, JSON1, recursive CTEs). Both tiers pass the full example suite (145/145). - Known limits: the Free native extension ships for Windows x64,
Linux x64 and Linux arm64 (
redb.dll/redb.so); macOS (osx-x64/osx-arm64.dylib) is built from the same CMake project but needs a macOS runner (CI matrix next). Pro has no native dependency and runs everywhere today. In-memory needsMode=Memory;Cache=Shared+ a kept-open connection.NUMERICmaps toREAL(exact-via-TEXTis a planned config option).
IUserProvider.GetUserByEmailAsync(string email)— new public API onRedBase.Core.IUserProviderfor case-insensitive lookup by_users._email. Filters out soft-deleted rows (_enabled = false). Email is NOT enforced unique at the schema level; the method returns the first active match ornull. Implemented inUserProviderBase;Users_SelectByEmail()SQL recipe added toISqlDialectand to every concrete dialect:PostgreSqlDialect,MsSqlDialect,SqliteDialect(Pro variants inherit the base implementation, no override needed). Unblocks federation email-conflict detection inredb.Identitywhere the previous probeGetUserByLoginAsync(email)was effectively dead code because self-register forbids@in login.
Changed
- SQLite stores all datetimes as REAL Julian day (UTC) instead of TEXT ISO-8601
(
RedBase.SQLite+RedBase.SQLite.Pro). The previous TEXT storage made range comparisons lexical, so a stored'2024-06-15 13:45:30'(SQLite space separator) never compared correctly against an ISO'2024-06-15T…'literal — date-range filters,MinRedbAsync/MaxRedbAsync,AggregateRedbAsync, window and group-by over datetime fields silently returned wrong/empty results, and a cluster heartbeat comparison could mark a live node dead. Every datetime column is now a REAL Julian number in UTC (_objects._date_create/_modify/_begin/_complete,_value_datetime,_values._DateTimeOffset,_users._date_register/_dismiss) — the native SQLite representation — sojulianday()/strftime()/datetime()/date()work directly and range comparisons are numeric and index-sargable. The JSON/wire shape is unchanged:get_object_jsonemits ISO viastrftime, the C# binder/reader convertDateTime/DateTimeOffset↔ Julian (ToOADate() + 2415018.5, UTC), and the nativepvtbuilder + ProProSqlBuildercompare againstjulianday('<iso>')on the constant side (sargable). Mirrors how PostgreSQL keepstimestamptzin UTC. Migration: SQLite databases created on the old TEXT schema are NOT auto-migrated — a fresh database (or a manual column rewrite) is required; mixing a TEXT-schema DB with this build yields wrong comparisons. Postgres/MSSql are unaffected. - Datetime analytics decode through a storage-agnostic hook (
RedBase.Core).Min/Max/AggregateRedbAsync, window and group-by select the raw datetime column (bypassingget_object_json) and hand the value to core converters (JsonValueConverter,AggregateResult.Get<T>, scalarConvert.ChangeType). To let SQLite's numeric Julian round-trip without teachingRedBase.Coreabout Julian days, a nullableTemporalDecoder.NumericDecoderextension point was added: when a numeric value targets a temporal CLR type and a decoder is registered, it is used; otherwise the existing path runs.RedBase.SQLite/.ProregisterSqliteJulian.FromJulianat configure time. The hook is null for Postgres/MSSql (which never return a number for a temporal column), so their behavior is unchanged. Pro reuses the same core converters, so one hook fixes Free and Pro alike. BackgroundDeletionServiceswitched from in-memory channel to DB polling (RedBase.Core). Earlier revisions used aChannel<PurgeTask>queue for low-latency wake-up plus a startup-onlyRecoverOrphanedTasksAsyncsweep for crash recovery — dual-state by design (channel in memory, trash rows in DB). Worker force-kills always left a tail of orphaned'pending'rows that the next startup had to drain in a flood of single-item purges; a periodic recovery sweeper to fix that would have raced against the live channel reader on fresh-pending rows. Redesign: DB IS the queue.ExecuteAsyncnow pollsGetOrphanedDeletionTasksAsyncevery 5 s, atomically claims each pending row via the existing cluster-safeTryClaimOrphanedTaskAsync, and purges in batches with the samePurgeTrashAsyncrecipe.IBackgroundDeletionService.EnqueuePurgeis now a no-op (kept on the interface so manualSoftDeleteAsync+EnqueuePurgecallers likeGroupService.AddMemberAsyncdon't break — the trash row they wrote is picked up by the next poll).QueueLengthis always 0; callers wanting the pending count should query the DB directly. Force-kill leaves nothing in memory because nothing was in memory — the next poll cycle finishes what was queued. Cleanup latency shifts from "milliseconds via channel" to "≤ 5 s via poll", but this is invisible to API consumers because objects are re-parented under the trash scheme synchronously bySoftDeleteAsyncand disappear from queries immediately; only the physical_valuescascade is deferred.
Fixed
Pro no longer calls the Free-only
get_object_jsonon the subtree-delete path (RedBase.Core+ all.Pro).TreeProviderBase.CollectDescendantIds(theDeleteSubtreeAsyncpath) lives in the shared base — Pro overrides the polymorphic load tree methods but not this one — and it used theTree_SelectPolymorphicChildrenrecipe, which embedsget_object_json. On PostgreSQL/SQL Server that function exists server-side in every tier, so it ran but needlessly materialized each child's full JSON just to read its id; on SQLite Pro (no native extension) it threwno such function: get_object_json. Fixed by collecting subtree ids through a new id-only dialect recipeTree_SelectChildrenIds(SELECT _id … WHERE _id_parent = …) — lighter for every dialect and tier. Pro source now contains zeroget_object_jsoncalls.DeleteSubtreeAsyncreturns the real subtree size (RedBase.Core, all dialects). It now returns the count of collected objects (self + descendants) instead of the rawDELETErows-affected, which under-counts on SQLite where the_id_parent ON DELETE CASCADEFK removes child rows as a side effect (PostgreSQL/SQL Server have no such cascade, so the value is unchanged there).Boolean keys/projections materialize correctly on SQLite (
RedBase.Core, shared).JsonValueConverternow accepts a JSONNumberas abool(nonzero → true): SQLite has no native boolean and stores it asINTEGER0/1, soGroupByArray/projection columns arrived as numbers and always readfalse. PostgreSQL/SQL Server (which emit JSONtrue/false) are unaffected.SQLite Free:
DistinctBy(field)now deduplicates (RedBase.SQLite). The native v2-pvt query builder ignoreddistinct_on(SQLite has noDISTINCT ON), soDistinctByreturned every row. Implemented it viaROW_NUMBER() OVER (PARTITION BY <field> ORDER BY o._id)in a chained_rankedCTE (WHERE _rn = 1), mirroringRedBase.SQLite.Pro.pvt_build_query_sqlnow reads thedistinct_onargument.SQLite Free: a multi-key filter no longer silently drops a
null/text shorthand leaf (RedBase.SQLite). InpvtSplitFilter's multi-key (implicit-$and) path,json_each'svaluecolumn loses type for a JSONnull(and strips quotes from text), so a shorthand condition like{"0$:ParentId": null}was rebuilt as invalid JSON and vanished whenever the filter had more than one key — e.g.WhereRedb(o => o.ParentId == null)combined with aWhere(...)prop filter returned rows that did have a parent. Each value is now re-encoded as a valid JSON atom (type-aware) before the per-key condition is rebuilt.Polymorphic
LoadAsync(IEnumerable<long>)no longer silently returns a base, non-genericRedbObjectfor a scheme whose CLR type exists (RedBase.Core+RedBase.Core.Pro, all dialects, Free and Pro). Thescheme_id → CLR Typeregistry was a one-time, per-cache-domain snapshot built only byInitializeClrTypeRegistryAsync, which (a) used a one-shot flag and never re-scanned, and (b) split assembly discovery across two sources (AssemblyLoadContext.Default.Assembliesfor auto-sync vsAppDomain.CurrentDomain.GetAssemblies()for the registry). In a host that loads modules into a pluginAssemblyLoadContext, or that callsSyncSchemeAsync<T>()explicitly afterInitializeAsync, the type was never registered, so a polymorphic bulk load fell back to a non-genericRedbObject(top level, silently) or threw (Pro nested materializer) — andloaded.OfType<RedbObject<TProps>>()came back empty even though typedQuery<TProps>()worked. A second, orthogonal mode: the registry lives inside a per-domain partition (domain = hash of the connection string), so two redb services on the same database but with slightly different connection strings — or a type synced under a different domain / by another cluster node — never shared the mapping.Rebuilt as two layers, each scoped to the natural lifetime of its fact:
ClrSchemeTypeIndex(new, process-global).schemeName ↔ Typefrom[RedbScheme]is a database-independent code fact, so it lives once per process, is shared by every cache domain, and is self-healing: assembly loads (including into pluginAssemblyLoadContexts) bump a generation counter and the index is rebuilt lazily on the next lookup. One broad assembly source for all.- Per-domain
scheme_id → Typeis now a lazy cache, not a snapshot.GetClrType(long)resolves on a miss viascheme_id → (this domain's DB) scheme name → global indexand backfills; newResolveClrTypeAsyncadds an async cold path that loads the scheme by id (covers cross-domain / another node). The one-shot flag no longer governs correctness;InitializeClrTypeRegistryAsyncbecame a re-runnable best-effort warm-up. - Scheme sync writes the binding authoritatively.
SyncSchemeAsync<T>()andEnsureSchemeFromTypeAsync<T>()registerscheme.Name → typeof(T)(global) andscheme_id → typeof(T)(this domain) at the one point where the type and a freshly-knownscheme_idco-exist — so an explicit, manual per-database sync makes the type polymorphically loadable regardless of[RedbScheme]presence,InitializeAsyncordering, plugin-ALC timing, or which node created the scheme.
Public API (
GetClrType,RegisterClrType,InitializeClrTypeRegistryAsync) is unchanged and the happy path is still a cache hit.InitializeAsyncis still required — it is just no longer the thing that makes the CLR registry correct. It also wires: the v2-pvt SQL module (EnsurePvtModuleDeployedAsync), the serializer type resolver (SetTypeResolver), theRedbObjectfactory + global provider (RedbObjectFactory.Initialize/RedbObject.SetSchemeSyncProvider), the internalUserConfigurationPropsscheme, metadata/props cache warm-up, and — withensureCreated:true— the base tables. Call it once per service/database; add a manualSyncSchemeAsync<T>()for any type not present at startup (e.g. a plugin module). Known limitation (pre-existing, multi-database): theSystemTextJsonRedbSerializertype resolver installed byInitializeAsyncis a process-global static bound to one service's cache domain — with two redb databases in one process the last-initialized service wins it, which can mis-resolve nested polymorphic deserialization for the other database on the serializer path. The ProProLazyPropsLoadernested path is unaffected (it uses its own service's cache).**Soft-deleted objects no longer leak into the materializer through nested
RedbObjectreferences (RedBase.Postgres,RedBase.MSSql,RedBase.SQLite- all three
.Pro).** Soft-delete is anUPDATE(move the row under a__TRASH__*bucket and flip_id_schemeto-10), not aDELETE, so an outbound_values._Objectpointer FROM a surviving object TO a trashed one is left intact. The object→JSON materializer only checked row existence by_id, not scheme — so loading the surviving parent followed the dangling edge and re-materialized the tombstone as if it were live data (a "zombie" nested object). Fixed by treating_id_scheme = -10as non-existent on the read path, in every place that resolves an object by id: PGget_object_json, MSSqldbo.get_object_json, the SQLite Free native C extension (redb_extension.credbObjectJson), and the Pro C# materializer'sMaterialization_SelectObjectsByIdsin all three dialects. Free and Pro are at parity: both returnnullfor the trashed nested reference. (Filtering the materializer query alone left Pro with an id-only placeholder where the target row used to load;ProLazyPropsLoadernow nulls any reference whose target was requested but not returned — soft-deleted or hard-deleted — at any depth, while preserving id-only placeholders at the depth boundary and for cyclic references, which are never requested.) The_values._Objectpointer is not mutated, so the nested reference reappears automatically if the target is restored from trash — soft-delete stays reversible. Top-level loads were already unaffected (the LINQ query filters by the concrete scheme, which is never-10); only nested-reference resolution leaked. Direct load-by-id (SelectObjectById/ the entry call) is intentionally left unfiltered so restore/trash-admin flows can still read trashed rows.
- all three
The object→JSON materializer now auto-redeploys to existing databases on upgrade (
RedBase.Postgres,RedBase.MSSql).EnsureDatabaseAsyncskips the fullredb_init.sqlonce_schemesexists, re-applying only the versionedv2-pvtmodule — butget_object_jsonand its helpers lived in the core init, so a bug fix to them (like the soft-delete fix above) would only have reached freshly-created databases. The whole materializer (get_object_json+get_objects_json/build_hierarchical_properties_optimized/build_listitem_jsonbon PG;dbo.get_object_json+build_properties/build_field_json/build_listitem_json/escape_json_stringon MSSql) moved fromredb_json_objects.sql(now deleted) into the module (v2-pvt/08_core_object_json.sql/09_core_object_json.sql), andpvt_module_version()was bumped (PG0.6.2 → 0.6.3, MSSql0.1.3 → 0.1.4, withQuery_PvtRequiredVersionin the dialects). Agit pull+ restart now re-applies the corrected functions viaEnsurePvtModuleDeployedAsync, no manualpsql/sqlcmdstep. The module's00_module_initguard no longer treatsget_object_jsonas an external prerequisite (it is module-owned). SQLite Free carries the same fix in the native C extension — it ships as the prebuiltredb.{dll,so,dylib}and must be rebuilt fromredb.SQLite/native(CMake) to pick it up; the Pro tier (pure C#) needs no rebuild.redb.Route.Sql.SqlProducerparameter binding now treats empty strings asNULL. A null upstream value (e.g. an OAuthclient_idthat is absent from a/connect/logoutbody) is routinely serialised through string-typed plumbing (HTTP header → header dictionary, JSON DTO → form/body) asstring.Empty. Binding that literally to atext/nvarcharaudit column wrote""instead ofNULL, soWHERE client_id IS NULLpredicates missed those rows andEvent_NullFields_WrittenAsDbNullon Postgres failed. NewNormalizeForDbhelper covers all four parameter-source priorities (explicit.Param(), exchange header,Dictionary<string,object?>body,IDictionary<string,object>body); non-string values and non-empty strings pass through unchanged.Test infrastructure —
ProductionBootstrapFixture.WithRedb(...)helper for the per-call scope pattern. The captive_fx.Redbis resolved at fixture build time from the rootServiceProvider, which means any concurrent caller (typically a Worker-side WireTap audit pipeline still flushing anINSERT INTO identity_audit_logwhile the test thread resumes) shares the same underlying provider connection. PG surfaced this asNpgsqlOperationInProgressException : A command is already in progress: INSERT INTO identity_audit_log, MSSQL asSqlConnection does not support parallel transactions, SQLite asSqliteException(SQLITE_BUSY). The Route DSL's parallel fan-out operators (WireTap,Multicast,Splitter,ScatterGather,RecipientList,Seda,Vm) already detach the per-exchange DI scope cache viaExchange.Clone()/CreateChild()skipping the__redb_scope:prefix and creating a brand-newIServiceScopeper branch — so route-level fan-out is safe. The fixture is the asymmetric case: test code that bypasses the route context and resolvesIRedbServicefrom the root SP directly. NewWithRedb<T>/WithRedboverloads open a fresh scope, resolve the per-scopeIRedbService, run the action, and dispose. Failing tests inSessionIntegrationTests,ConsentIntegrationTests,H8FederationPolishTestsmigrated to the helper; the captiveRedbproperty is retained (and documented) for bootstrap-time access where no Worker is processing yet.SqliteDialectandMsSqlDialectFormatCaseInsensitiveLikenow emitESCAPE '\'.UserProviderBase.EscapeLikeWildcardsescapes_,%,\with a leading backslash so the user-supplied search value is matched literally — this depends on the dialect honouring\as the LIKE escape character. PostgreSQL does, by default. SQLite and SQL Server do NOT without an explicitESCAPEclause, so a literal_in the search input (very common in synthetic test logins / e-mails likereset_53f4f0f9@example.com) survived as a wildcard match for ANY single character — a one-character mismatch from any genuine row in the table. Concretely:GetUsersAsync(EmailExact = "reset_53f4f0f9@example.com")searched for_email LIKE 'reset\_53f4f0f9@…'and returned zero matches because the SQLite/MSSQL engine interpreted the leading backslash as a literal character rather than an escape prefix. Surfaced as thedemo_password_reset"no enabled user for supplied email" silent drop on SQLite and MSSQL (PG passed). The same engines reading the same data via Postgres returned the row; the rest of the lookup machinery (Enabled = true, ordering, etc.) was working correctly all along.Pool-poisoning guard on all three provider connection acquires (
SqliteDataSource.EnsureCleanTransactionState, newSqlRedbConnection.EnsureCleanTransactionStateAsync,NpgsqlRedbTransactiondiagnostic-only) — the swallow-on-rollback path in every*RedbTransaction.DisposeAsynchad quietly returned a driver-level connection to the pool with a still-active transaction on the underlying handle. The first caller to draw that connection from the pool would then fail with a driver-specific message that obscured the real cause:- SQLite:
SqliteException(SQLITE_ERROR): cannot start a transaction within a transactionon the nextBEGIN IMMEDIATE. - SQL Server:
InvalidOperationException: SqlConnection does not support parallel transactionson the nextBeginTransaction()— 31 of the recent MSSQL test failures took this exact stack (SqlRedbConnection.BeginTransactionAsync→SaveAsyncBEGIN-NEW branch withIsInTransaction=Falseat the wrapper level). - PostgreSQL: usually masked because Npgsql's pool acquire runs
DISCARD ALLas a built-in reset, so the leak almost never surfaces in practice. The fix still lands here because semantic correctness should not depend on driver-specific pool behaviour; the same[Diag-TX-LIFECYCLE-PG]anchors mean a future regression of this shape can never go silent.
The shape of the fix is identical across providers:
- Every freshly-opened pooled connection now runs a speculative
ROLLBACKagainst the underlying handle right after the existingApplyPragmas/ open path. The driver-specific "no transaction is active" error (SQLiteSQLITE_ERROR(1), SQL Server error 3903) is the normal/clean case and is silently caught; an actual successfulROLLBACKmeans the pool DID hand us a dirty handle and is logged so the source of the leak is observable. Idiomatic mirror of Npgsql's built-inDISCARD ALLreset. CommitAsyncon every wrapper now runs the underlying_transaction.CommitAsync()inside atry/catch; on failure the wrapper speculatively rolls back so the driver-level connection returns clean, then re-throws so the caller still sees the original exception. Both the original failure and any cascading rollback failure emit[Diag-TX-LIFECYCLE-{SQLITE,MSSQL,PG}]log lines.RollbackAsyncandDisposeAsynclikewise log instead of silently swallowing —DisposeAsynccannot throw (Dispose contract), but any leak that escapes here is now visible and is cleaned up by the next pool acquire's sentinelROLLBACK.
- SQLite:
SqliteDialect.FormatPaginationhandles the bare-OFFSETcase correctly.OFFSET mon its own is a SQLite parser error (SQLITE_ERROR: near "OFFSET": syntax error) — the engine only accepts theLIMIT n OFFSET mform. The dialect now emitsLIMIT -1 OFFSET mfor the offset-without-limit case (SQLite reads-1as unlimited); theLIMIT nandLIMIT n OFFSET mcases stay unchanged. Surfaced via a LINQ.Skip(N)chain without a matching.Take(M)— common in trim/cleanup paths (e.g. "delete everything older than the keep-newest-N entries"), which had been silently short-circuiting on SQLite for any caller that wrapped it in a swallow-catch.All three provider
IRedbTransactionimplementations (SqliteRedbTransaction/NpgsqlRedbTransaction/SqlRedbTransaction) now release the connection's_currentTransactionslot onCommitAsyncandRollbackAsync, not just onDisposeAsync. The_currentTransactionfield on every*RedbConnectionwas previously cleared only by the dispose callback. A code path that issued a query betweenawait tx.CommitAsync()andawait usingscope exit would still see_currentTransaction != nulland theCreateCommandwrapper would attemptcmd.Transaction = closedTx— Microsoft.Data.Sqlite throws"The transaction object is not associated with the same connection object as this command."outright, Npgsql / Microsoft.Data.SqlClient happen to tolerate the assignment but the semantics should not depend on driver tolerance.CommitAsyncandRollbackAsyncnow invoke the same_onDisposecallbackDisposeAsyncuses; the callback is a single() => _currentTransaction = nullso the second invocation fromDisposeAsyncis a no-op. Manifested on SQLite asTransactionIntegrityTests.CommitAsync_PersistsWritesfailing the visibility probe right after commit.SqliteRedbConnection.CreateCommandgatescmd.Transaction = …on_currentTransaction.IsActive. Defense-in-depth alongside the transaction-class fix above — even if some future code path forgets to clear_currentTransaction, commands fired after Commit / Rollback bind to no transaction (running against the autocommit connection) instead of throwing.SqliteDataSource.ApplyPragmasnow setsjournal_mode=WALandsynchronous=NORMALon every connection. Without WAL, Microsoft.Data.Sqlite defaults to journal modeDELETEwhere writers block readers — concurrent reads during an open write tx surface asSqliteException: database table is locked: <name>, breaking redb's check-then-save patterns and any uncommitted-read visibility probe. WAL is the recommended production journal mode and matches the configuration used by ASP.NET Core Identity's SQLite sample plus most third-party deployments.ProducerTemplate.SendAsync/RequestBodyauto-start the cached producer.IProducerTemplateoverloads resolved an endpoint, cached a freshIProducerfromendpoint.CreateProducer(), and calledproducer.Process(exchange)directly. For DirectVm / Direct / Seda producers (which don't extendConnectableProducer) this was fine; for every transport that does (HttpProducer,KafkaProducer,AmqpProducer,AzureServiceBusProducer,MqttNetProducer,RabbitMqProducer,RedisProducer,SmtpProducer,LdapProducer,WmqProducer, …)EnsureStarted()threw"<name> has not been started. Call Start() first."because the cached producer was never started.SendAsync(IEndpoint, IMessage),SendAsync(IEndpoint, object), and the twoRequestBody(IEndpoint, …)overloads now callawait producer.Start(ct)betweenGetOrCreateProducerand the firstProcess. The started flag short-circuits viaInterlocked.CompareExchangeso the extra call is a one-time setup per producer / process-lifetime and a no-op on every subsequent send. Surfaced when wiring outbound HTTP webhook delivery throughIProducerTemplate.SendAsync(url, message)inredb.Identity(W1 / outbound webhook subscriptions). Also documented inredb.Route/CHANGELOG.md.BackgroundDeletionServicedrains its queue synchronously on graceful shutdown (RedBase.Core). Previously the host'sStopAsynconly cancelled the read loop — tasks that had been enqueued but not yet processed were lost; tasks mid-process left their trash containers instatus=runningin the DB. The next startup'sRecoverOrphanedTasksAsyncthen drained those leftover containers one-by-one (each emitting aPurgeTrash completed. Deleted=1log line — the flood observed after a worker restart). Override ofStopAsyncnow: marks the channel writer as complete, pulls every remaining task and processes it synchronously (no inter-batch delays), and respects the host's shutdown deadline (HostOptions.ShutdownTimeout, default 30 s for ASP.NET). Helps only when the host actually callsStopAsync(graceful shutdown via Ctrl+C / SIGTERM,IHost.StopAsync()); a hard process kill (Stop-Process -Force/ SIGKILL) still leaves orphans the next startup picks up — same behavior as before.PurgeTrash completedlog line dropped from INF to DBG (RedBase.Core). The line fires once per trash container processed byBackgroundDeletionService. Each high-level DELETE (e.g.redb.Identityadmin/self-service user delete, DCR cleanup, federation provider delete) ships its ids as a single call, so almost every container has exactly one object inside and the log spam readsDeleted=1per item. Worker restarts compound the noise viaRecoverOrphanedTasksAsyncdraining the accumulated backlog one-by-one. Operators who need per-purge visibility now enable DBG for theRedBase.Core.Providers.Base.ObjectStorageProviderBasecategory.UserProviderBase.DeleteUserAsyncandUsers_SoftDeleteSQL recipe no longer mutate_login(RedBase.Core,RedBase.Postgres,RedBase.MSSql,RedBase.SQLite). Previously the soft-delete path appended a_DEL_<timestamp>suffix to BOTH_loginand_name. PostgreSQL'sprotect_system_userstrigger correctly flagged that as "Cannot change user login" —_loginis immutable for ALL users by the schema contract, and conceptually "changing login" is a delete-and-create sequence, not an update. Fix: the SQL recipe is nowUPDATE _users SET _name = ?, _enabled = ?, _date_dismiss = ? WHERE _id = ?(login column dropped), and the C# call passes only the suffixed name. Login STAYS as-is so re-registration with the same login is blocked while the soft-deleted row exists. Affects any caller ofIUserProvider.DeleteUserAsync— most visiblyredb.Identityadmin DELETE/users/{id}and the new self-service DELETE/me, both of which previously returned 500 ("Database temporarily unavailable" wrapping the trigger violation).Pro tree loading no longer calls the server-side
get_object_jsonfunction (RedBase.Core.Pro, affectsRedBase.Postgres.Pro+RedBase.MSSql.Pro).TreeQuery(...).ToTreeListAsync()/ToRootListAsync()pull ancestor nodes viaTreeQueryProviderBase.LoadObjectsByIdsAsync(both the generic and polymorphic overloads), which were routing throughget_object_json. When a Pro lazy props loader is present, both overloads now load base_objectsrows with a plainSELECTand materialize Props entirely in C# via the injected loader (ProLazyPropsLoader→ PVT) — the same path the Pro object-storage provider already uses. The Free path is unchanged (still usesget_object_json). This restores the Pro invariant that the Pro engine never depends on database-side materialization functions. (Latent across all Pro providers; surfaced while bringing up the upcoming SQLite Pro provider.)GroupBy / Window projection value conversion (
RedBase.Core, Free + Pro).ConvertJsonValue(grouped and tree-grouped windowed queryables) now unwrapsNullable<T>and handles JSONNumber → bool(a boolean group key serialized as0/1rather thantrue/false),Number → float, andString → bool/Guid/DateTimeOffset. Previously these fell through to a string and threwObject of type 'System.String' cannot be converted to type 'System.Boolean'when a projection member's type didn't match the JSON shape. PostgreSQL was unaffected because it emits nativetrue/false.MSSql Free:
DISTINCTwith paging/order no longer fails with "The multi-part identifier 'o._id' could not be bound" (RedBase.MSSql, v2-pvt module0.1.2 → 0.1.3).pvt_build_query_sqlwraps the@distinct = 1row-source in a derived table (_dist) that projects only[_id], but appended the outerORDER BYbuilt with the inner alias prefix (o./_pvt_cte.), which is not in scope outside the wrapper. AnyDistinct()combined withTake()/OrderBy(e.g.Query<T>().Distinct().Take(100)) threw. The outer order now references the projected[_id](new@order_sql_dist) in all three distinct branches (Shape A pure-base, Shape B/C pivot, tree).EnsurePvtModuleDeployedAsyncredeploys the bundled module on the version bump. PostgreSQL was unaffected (it emits a singleSELECT DISTINCT o._id … ORDER BY o._idwithoin scope — no_distwrapper).
3.0.0
Added
PG Free: full v2-pvt query engine reaches Pro-parity (0.5.x → 0.6.1). The PostgreSQL Free path got the feature-complete v2-pvt module ahead of MSSql Free (commits 2026-05-21 … 2026-05-28). Before this series the Free path was emitting
-- not available in Open Sourcestubs for several preview surfaces and was missing several Pro-only operators. Now in Free on PG:- Universal "no black box" SQL preview for
GroupBy/Window/GroupedWindow/Tree-*via two-pass compile (pvt_build_*_sql); tree previews resolve the subtree and delegate to the matching non-tree preview with a-- Tree …: subtree resolved to N object(s)header. Sql.Function<T>whitelist at the SQL boundary (17_pvt_expr.sql) with a hardcoded ELSIF chain andRAISE EXCEPTIONfor non-whitelisted names; parser routesSql.Function<T>(name, args)toCustomFunctionExpression(FREE-OVER-PRO §2.4).ValueTuplecomposite dict keys (Dictionary<(int,int), V>) consistently encoded as Base64-JSON on both write and read sides (FREE-OVER-PRO §2.2).arr.Length/coll.Countin filters via the array-awareFacetFilterBuilder(.$countmodifier in Free);e.Tags.Any()1-arg form mapped to<field>.$length > 0.Take(0)returns empty instead ofArgumentException.HAVINGparser +ArrayGroupBywith PVT agg arrayunnest(19_pvt_agg_expr.sql) — fixes42883 function sum(bigint[]) does not exist; 26_pvt_array_groupby.sql added.ListItem.Value/.Aliasvia a singleLEFT JOIN _list_items(v2-pvt 0.6.1) — plan-shape parity with Pro; replaces correlated subquery per field.- Nested-dict CTE pushdown for
Field[key].Child(FREE-OVER-PRO §2.x): outerWHEREreferences the already-built pivot column instead of a redundantEXISTSover_values. - Auto-deploy of the v2-pvt bundle on version mismatch (see the matching item below — same infrastructure serves both PG and MSSql).
The MSSql Free engine described next ports this PG Free baseline; the parity line in the next item ("145/145 parity with PG Free") refers to this newly-completed PG Free feature set, not a pre-existing one.
- Universal "no black box" SQL preview for
MSSql Free: full v2-pvt query engine (0.1.0 → 0.1.3) — 145/145 parity with PG Free. The old MSSql Free path generated a wide inline CASE WHEN aggregate; it is now replaced with the Pro-shape CTE: a single pass over
_valuesusingMAX(CASE WHEN _id_structure = X AND _array_index IS NULL THEN ...)and a singleLEFT JOIN _list_items. All modes present in PG Free are implemented: flat/tree, scalar/array/dict fields, ListItem (.Id/.Value/.Alias), same-scheme nested POCO (compound path),OrderBy/DistinctBy/Take/Skip,GroupBy/HAVING,ArrayGroupBy(viaOUTER APPLY), array aggregates ($count,$sum/$avg/$min/$maxover_Long/_Double/_Numeric/_DateTimeOffset), array operators ($arrayContains,$arrayAny,$arrayCount*,$arrayAt,$arrayStartsWith, etc.),Sql.Function(whitelist),$expr, null semantics ($exists/$notNull). The SQL module is split into 27 source files under redb.MSSql/sql/v2-pvt/ assembled into a singlepvt_bundle.sqlby MSBuild. Delivery stages: Stage 1 (pivot CTE) → 2a (tree TVFs) → 2b (tree provider) → 2c.E (nested-dict accessorField[key].Child) → 0.1.1 LIKE-pattern fix → 0.1.2 string$constunwrap + ListItem$arrayContains→ 0.1.3 nested-dict CTE pushdown + outerWHEREreferences pivot column instead of a redundantEXISTS. Shape parity with Pro throughout:_id_scheme+extra_where+ tree-filter pushed into inner_objectssubquery, narrow-with-nested CTE (skips_valuesJOIN when no scalar sids), stable defaultORDER BYwhen paging without an explicit order.Auto-deploy v2-pvt bundle on version mismatch (both databases).
ISqlDialectgainedQuery_PvtRequiredVersion()— the semver the embedded bundle ships.RedbServiceBase.EnsurePvtModuleDeployedAsyncreadspvt_module_version()onInitializeAsync(), compares with an exact-match, and automatically applies the embeddedpvt_bundle.sqlresource when the deployed version differs. No more manualDROP FUNCTION … CREATE FUNCTION …after a SQL change. The MSBuild targetConcatenateSqlFilesregenerates the bundle whenever any.sqlsource changes (hooked toDispatchToInnerBuildsfor multi-TFM builds;EmbeddedResourceuses an explicitLogicalName— without it MSBuild silently replaces-with_in resource paths, causingGetManifestResourceStreamto returnnull).Pro:
GroupBy+HAVINGvia PVT pipeline on both providers (Postgres.Pro + MSSql.Pro).HavingAsyncexisted in Free but had no Pro counterpart. Added full HAVING parser in the shared facet layer (FacetFilterBuilder), SQL generation in both Pro providers, and a base test suite in GroupByHavingTestsBase with per-dialect wrappers (PG, PG.Pro, MSSql.Pro). 33/33 HAVING + 6/6 no-HAVING — all green.Pro:
GroupByover array fields (ArrayGroupBy) — unified implementation for Postgres.Pro + MSSql.Pro. PG.Pro uses an inlineGroupByArrayoverride with PVT agg arrayunnest; MSSql.Pro has its own override.GroupBy(items => items.SelectMany(o => o.Skills))with aggregates works on all four tiers (PG Free, PG.Pro, MSSql Free, MSSql.Pro).MSSql Pro:
AggregateBatchparity with PG.Pro — non-numeric MIN/MAX and inline filter subquery.MinAsync/MaxAsyncoverstring/DateTime/Guidfields and aWherefilter inside a batch aggregation now produce the same query shape as PG.Pro (PVT CTE + outer aggregate).MSSql Free: pushdown parity with Pro/PG for expression-form predicates and
$expr— the filter-splitting optimizerpvt_split_filternow pushes top-level$eq/$ne/$lt/$lte/$gt/$gte/$like/$ilike/$in/$nin/$between/$null/ $notnull/$contains/$startsWith/$endsWithexpressions and arbitrary boolean$exprtrees into the inner_objects osubquery (Shape A) when all$fieldreferences resolve tokind='base'. If any props field is present the node stays in the residual (Shape C). The new classifierpvt_expr_is_base_onlymakes this decision; the pushdown SQL itself is generated by the existingpvt_build_where_from_jsonwalker (extended with a$exprbranch). Covered by 4 functional and 3 shape-inspect tests in99_smoke_auto.sql(195 PASS / 0 FAIL / 1 SKIP).
Fixed
- Schema sync now honors
Configuration.DefaultStrictDeleteExtra(FREE-OVER-PRO §4 #1). Prior to this fixRedbServiceConfiguration.DefaultStrictDeleteExtrawas set by builders, copied across configuration clones and read fromappsettings, but no execution-path code consumed it —SchemeSyncProviderBase.SyncSchemeAsync<T>hardcodedstrictDeleteExtra: true, so old binaries restarting in a multi-version rolling deploy would unconditionally remove_structuresrows added by the new binary, and every_valuesrow referencing those structures along with them. On PostgreSQL this is done via the FK_values._id_structure -> _structures._id ON DELETE CASCADE(redbPostgre.sql:215). On MSSQL the same effect is produced by theINSTEAD OF DELETEtriggerTR__structures__cascade_values(redbMSSQL.sql:717) — the FKNO ACTIONat redbMSSQL.sql:270 is a workaround for the MSSQL multiple-cascade-paths restriction, not a behavioral difference.SyncSchemeAsync<T>now reads now readsConfiguration.DefaultStrictDeleteExtrainstead. The default value is preserved (true) so users on the default config see no behavioral change. Behavioral change: the built-in presetsDevelopment,HighPerformance, andMigration(inPredefinedConfigurations.cs) already declaredDefaultStrictDeleteExtra = false; that setting was silently ignored before and now actually takes effect — apps on those presets will no longer auto-delete_structuresrows missing from thePropsclass on startup.
Added
a fallback to ROW_NUMBER() OVER (PARTITION BY <key> ORDER BY (SELECT 1))
WHERE _rn = 1(symmetric with the Free path), plus support forCoalesceExpressionin theDistinctBykey.
PG v2-pvt 0.6.1: ListItem
.Value/.Aliasnow uses a singleLEFT JOIN _list_itemsinstead of a correlated subquery per field — plan-shape parity with Pro. Additionally: nested-dict predicates in the outerWHEREnow reference_pvt_cte.[<field>](the already-built pivot column) instead of re-running a separateEXISTSover_values.MSSql Free:
ORDER BY $expron base fields no longer produces "constant in ORDER BY" — two regressions fixed: (1)pvt_collect_fieldsdid not walk$exprnodes in order entries, so a field likeAgewas not collected, the shape was classified as A, andpvt_b2_expr_sqlemitted/*unknown-b2-field:Age*/NULLturningAge*2into a constant; (2)pvt_build_order_conditionspassed a trailing-dot alias (_pvt_cte.) intopvt_b2_expr_sql, producing the double-dot_pvt_cte..[_name]for base fields inside$exprORDER. Both sites fixed.arr.Length/coll.CountinWherefilters no longer crash on array PVT columns —e.Skills!.Length >= 3was translated toLENGTH(text[])and raised PostgreSQL error 42883. BaseFilterExpressionParser now emitsPropertyFunction.Count(instead ofPropertyFunction.Length) for CLRUnaryExpression(ArrayLength)nodes. In Pro this producesCOALESCE(array_length(col,1), 0); in Free, FacetFilterBuilder.TryBuildArrayLengthCountFilter translates the filter to the PVT modifier.$count.PropertyInfogained an optionalFunctionSourceTypefield so the facet builder can distinguish arrays from strings when choosing the modifier. Covered byPropertyFunction_ArrayCount_Filterson both tiers.Take(0)now returns an empty result instead ofArgumentException— validation inRedbQueryable.Take()andTreeQueryableBase.Take()relaxed fromcount <= 0tocount < 0to match standard LINQ semantics (Enumerable.Take(0)→ empty). Affects both tiers (Free and Pro), flat and tree queries. Covered byTake_Zero_ReturnsEmpty_WithoutThrowingandTake_Zero_ReturnsEmpty_OnTreeQueryinPvtAuditTestsBase.
Tests
PostgresFreePvtAuditTestsmoved to the shared base PvtAuditTestsBase and now runs against bothPostgresFixture(Free) andPostgresProFixture(Pro) — a regression on either tier fails immediately. Added tests forTake(0)(flat + tree),Take(-1)(still throws), andDistinctByon a tree query.- Three audit probes from FREE-OVER-PRO §2.x confirmed working on both
tiers without any SQL/parser changes — tests were the only missing piece:
DictTupleKey_PerformanceReviews_FiltersByCompositeKey(§2.2) —ValueTupledict keys are encoded byRedbKeySerializerto Base64-JSON consistently on the write side and in BaseFilterExpressionParser L602.ObjectRef_CurrentProject_NotNull_Filters/ObjectRef_CurrentProject_IsNull_Filters(§2.3, null-check path) —e.CurrentProject != null/== nullonRedbObject<T>?fields works via$exists/$ne null.SqlFunction_Coalesce_Filters+SqlFunction_UnknownName_ThrowsWhitelistViolation(§2.4) —Sql.Function<T>(name, args)is routed by the parser toCustomFunctionExpression,FacetFilterBuilderemits{"$<funcname>": [...]}, andpvt_build_scalar_expr(17_pvt_expr.sql) implements the whitelist with a hardcoded ELSIF chain andRAISE EXCEPTIONfor unknown names.- Full PG suite (Free + Pro): 328 passed / 0 failed / 2 skipped. The
two remaining skips are
ObjectRef_CurrentProject_NestedField_Filters(cross-scheme JOIN path, confirmed broken in both tiers — requires new infrastructure in both PVT andProQueryProvider).
- ListItem
.Value/.AliasOrderBycapability gate — PG Free PVT sorts byStatus.Value/.Aliascorrectly (on par with Pro); theif (IsPro)guard inListItem_OrderByValue_SortsAlphabetically/ListItem_OrderByAlias_SortsAlphabeticallywas overly conservative. Added virtualSupportsListItemValueAliasOrdering(default =IsPro) inListTestsBase;PostgresListTestsoverrides totrue. Result: PG Free + PG Pro + MsSql Pro pass with strict ordering; MsSql Free remains gated (insertion-order only —ORDER BYon a JSON expression is ignored).
Documentation
- New section "Schema lifecycle and multi-version deployments" in the
root README.md: documents read=graceful / write=destructive,
the
services.AddRedb(... .Configure(c => c.DefaultStrictDeleteExtra = false))opt-out, the new warning log, and the equivalent cascade semantics across backends \u2014 PostgreSQL uses FKON DELETE CASCADEon_values._id_structure, while MSSQL achieves the same effect through theTR__structures__cascade_valuesINSTEAD OF DELETEtrigger (MSSQL FK isNO ACTIONonly to work around the multiple-cascade-paths restriction). - Rewrote docs/FreePvtQuery/FREE-OVER-PRO.md
§4: marked F0+F1 (this release) as done, demoted F3 (default flip) to a
major-version task, made the cache-state-dependent nature of the Pro
ChangeTracking destructiveness explicit (per-instance cache refresh window),
and corrected §4.1 — the previous "obligatory
DefaultStrictDeleteExtra = false" guidance was non-functional before v2.0.3 and is now actually wired. - Updated docs/FreePvtQuery/FREE-OVER-PRO.md:
H1 (
Take(0)) marked fixed; H8 (treeDistinctBy) re-classified as already implemented in both tiers; §0 and §2.x updated for §2.2 / §2.3-null / §2.4 closures; §1 #5 (Sql.Function) no longer marked unimplemented; §2 #3, #5, #6 marked done; added §2 #6b (deferred nested-field cross-scheme JOIN — confirmed as a two-sided gap in Free PVT and ProSchemeFieldResolver); added §3 #11 (MsSql Free ignoresOrderBy(Status.Value)/.Alias).
2.0.2
Changed
EavSaveStrategyrenamed toPropsSaveStrategy— users frequently asked whether RedBase uses the EAV (Entity-Attribute-Value) pattern. RedBase's storage model resembles EAV in structure (_objects+_values), but differs in key ways: schemes are strictly typed, fields are schema-bound (not free-form key-value pairs), and the query layer compiles LINQ directly to typed SQL without generic key-value lookups. TheEavprefix was misleading. Renamed throughout:EavSaveStrategyenum →PropsSaveStrategyRedbServiceConfiguration.EavSaveStrategyproperty →PropsSaveStrategy- JSON/appsettings key
"EavSaveStrategy"→"PropsSaveStrategy"(breaking: updateappsettings.json/ environment variables if set explicitly) Tsak:Redb:EavSaveStrategyconfig key →Tsak:Redb:PropsSaveStrategy
2.0.1
Fixed
Pro Props LINQ→SQL: 80× perf regression on mixed base + props filters — when a query combined a base-field predicate (
WhereRedb(o => o._id_parent == X),parentIds.Contains(o.ParentId.Value), etc.) with a props predicate (Where(props => ...)), the base predicate was applied as an outerWHEREafter the PVT CTE aggregation (array_agg FILTERon Postgres /MAX(CASE WHEN ...)on MSSql). PVT was built over the entire scheme (millions of rows) and only then filtered down — observed 894 ms vs 11 ms.- The base predicate is now compiled with an empty table alias and pushed
into the inner
(SELECT _id FROM _objects WHERE _id_scheme = X AND <baseFilter>)subquery of the PVT CTE. The outer baseWHEREis removed when pushdown fires, so generated SQL contains no duplicated predicate. - Affects flat queries (
ToListAsync,CountAsync,ExecuteDeleteAsync), aggregations (SumAsyncetc., aggregate batch), and window functions. - Both providers fixed symmetrically (
redb.Postgres.Pro,redb.MSSql.Pro). - Added new public helper
ProSqlBuilder.CompileBaseFieldsForObjectsSubquerythat emits base-field SQL without theo.alias prefix for use inside the_objectssubquery.
- The base predicate is now compiled with an empty table alias and pushed
into the inner
Pro Props LINQ→SQL: same regression on tree queries — the tree variant (
AsTreeQuery,AsTreeWindowQuery) had the identical anti-pattern: base-field predicate was applied as outerWHEREafter the tree pvt_cte aggregation (_objects oo JOIN tree t JOIN _values vwitharray_agg FILTER/MAX(CASE WHEN)). With large trees + selective base filter (e.g._id_parent = X), PVT aggregated all tree members before the predicate narrowed the result.- Base predicate is now pushed into the tree pvt_cte's inner
WHERE oo._id_scheme = X AND <baseFilter>(withooalias to disambiguate_id/_id_parent/_hashfrom the joinedtree t). - The Tree-Window path (
BuildTreeWindowSqlTypedAsync) now also pushes the base filter intoBuildPvtSubquery's additional WHERE (o._id = ANY(ARRAY(SELECT _id FROM tree)) AND <baseFilter>on Postgres,o._id IN (SELECT _id FROM tree) AND <baseFilter>on MSSql). - Tree-Aggregation uses correlated per-row subqueries and is not affected; Tree-GroupBy and Tree-GroupedWindow already pushed correctly.
CompileBaseFieldsForObjectsSubqueryextended with optionalbaseTableAliasparameter (default"") — backwards-compatible for the flat case; tree case passes"oo".- All 134 existing tree + ParentId integration tests pass on both providers.
- Base predicate is now pushed into the tree pvt_cte's inner
Added
- Integration tests for the pushdown contract in
redb.Tests.Integration/Tests/Base/WhereTestsBase.cs
covering
WhereRedb(parentIds.Contains(...)) + Where(props)forToListAsync,CountAsync, andOrderBy + Take. Tests run for both Postgres Pro and MSSql Pro fixtures.
2.0.0
Changed
- License changed from MIT to Apache-2.0 for all OSS packages
(
redb.Core,redb.Postgres,redb.MSSql,redb.CLI,redb.Export,redb.Templates,redb.PropsEditor).- Apache 2.0 adds an explicit patent grant (§ 3) and termination clause — stronger protection for users and contributors.
- All previously published versions (≤ 1.3.0) on nuget.org remain under MIT.
- Pro packages (
*.Pro,redb.Licensing) are unaffected — still under the commercial license inLICENSE-PRO.txt. - Every nupkg now ships
LICENSE+NOTICEfiles (Apache 2.0 § 4 attribution). - Contributions are now accepted under Apache-2.0; see
CONTRIBUTING.md.
- Strong-Name signing is now active for all Pro assemblies
(Public Key Token:
8e6fea371ffeb38e). This is a binary-identity change for Pro consumers — assembly identity differs from previous unsigned releases.
Why this is a major version bump
- License change is a downstream-compliance breaking change.
- Pro Strong-Name change is a binary-identity breaking change.
- No source-level API changes vs 1.3.0.
1.3.0
Fixed
- Nullable
.ValueinWhereRedbresolved to wrong column —o.ParentId.Value == 42generated SQL against_idinstead of_id_parent. Fixed in all parsers and Pro SQL compilers. .HasValuegeneratedfield = trueinstead ofIS NOT NULL—o.ParentId.HasValueproduced type mismatch (bigint = boolean). Now emitsIS NOT NULL/IS NULL.- Props cache skipped hashless objects —
LoadPropsForManyAsyncnever added objects without_hashtoneedToLoad, leaving their Props null. - Missing
_hashin nested object SQL —Materialization_SelectObjectsByIdslacked_hashcolumn; nested saves corrupted existing hashes. - Lexicographic ArrayIndex sorting — arrays with 10+ items sorted as
"10" < "2", causing false ChangeTracking updates. Now uses numeric sort. - Duplicate RedbObject in SaveAsync —
CollectNestedRedbObjectsFromPropertiesdidn't deduplicate by ID, crashing ChangeTrackingToDictionary. - MERGE duplicate row in ChangeTracking —
BulkUpdateValuesAsyncreceived duplicate_idvalues. AddedDeduplicateValueUpdatesguard. - Missing
ArrayParentIdfor nested RedbObject refs —ProcessSingleIRedbObjectdidn't set_array_parent_idinside business class arrays, breaking ChangeTracking diffs. - Guid field not persisted —
SetSimpleValueByTypelackedcase "Guid", saving to_Stringinstead of_Guidcolumn. DeleteSubtreeAsyncthrew "Scheme for type Object not found" — replacedGetDescendantsWithUserAsync<object>()with polymorphicCollectDescendantIds.LoadTreeAsync(maxDepth: 1)returned no children — off-by-one:maxDepthwas decremented before recursion instead of inside it.- MsSql Pro
Where(x => x.Field == null)—CompileNullChecknow correctly generatesIS NULL/IS NOT NULLagainst PVT CTE columns. - MsSql Pro
DistinctByreturned duplicates — addedROW_NUMBER() OVER (PARTITION BY ...)CTE wrapper withWHERE _rn = 1. - MsSql DELETE stored procedures —
SET NOCOUNT OFFfor correct affected-rows count fromExecuteNonQueryAsync. - MsSql Free WHERE null (
$exists false) — generatesNOT EXISTS(...)instead of1=0. - MsSql Free OrderBy — zero-padded numeric conversion;
ROW_NUMBER()preserves sort through JOIN. get_object_json/build_field_json— returns"properties": nullfor objects without_values. Array/dict without head records return NULL instead of[]/{}.- KeyGenerator shared cache — domain-isolated
KeyCacheDomainprevents duplicate key violations across providers. - SaveAsync deadlocks —
ORDER BY _id+ROWLOCK(MsSql) in locking queries; consistent lock ordering. - MsSql reader-writer deadlocks — init script enables
READ_COMMITTED_SNAPSHOT ON. SchemeFieldResolvernot domain-isolated — per-domain cache with 5-min TTL and self-heal on cache miss.SyncSchemeAsyncdidn't cache scheme — schemes cached immediately after sync, eliminating extra DB roundtrips.- PropsSaveStrategy ignored in Tsak Worker — reads
Tsak:Redb:PropsSaveStrategyfrom config. SimplePasswordHasher— replaced custom compare withCryptographicOperations.FixedTimeEquals.- GroupBy aliased keys returned null — alias resolution for
g.Key,g.Key.X,g.Key.X.Idpatterns. - ListItem
Containspassed raw objects —IRedbListItem→.Idconversion inVisitEnumerableContains/VisitCollectionContains. - MsSql ListItem operators —
$in,$notIn,$arrayContainsnow use_listitemcolumn. - Postgres
$arrayContainsfor ListItem arrays —_listitemcolumn withbigintcast instead of_String. - Pro OrderBy
ListItem.Value/ListItem.Alias— subquery JOINs_list_itemsfor text sorting; CTE bypassed for ListItem fields. - GroupBy/Window base field filter crash —
WhereRedbon base fields (ValueString,Name, etc.) combined withGroupBy/Window/GroupedWindowproducedpvt._value_string does not exist. Root cause: naive.Replace("o.", "pvt.")put base field filter outside PVT subquery whereo.doesn't exist. Fix: base filters now injected inside the inner subquery WHERE clause (before aggregation), props filters remain on outerpvt. Affected 9 sites across Postgres.Pro and MSSql.Pro (Grouping, TreeGrouping, TreeWindow, TreeGroupedWindow, GroupedWindow). Also fixes Postgres TreeGroupedWindow where base filter was silently ignored.
Added
save_object_jsonSQL functions (Postgres + MsSql) — inverse ofget_object_json, writes JSON back via DeleteInsert.DeadlockRetryHelper— automatic retry with exponential backoff for deadlock exceptions.BcryptPasswordHasher— bcrypt (work factor 12) with backward-compatible SHA256 verification and lazy rehash.
1.2.14
Fixed
- Free projection double-load bug —
LazyPropsLoader.LoadPropsForManyAsyncignoredprojectedStructureIdsand reloaded full objects viaget_object_json, overwriting partial Props returned by SQL projection. Fix:SkipPropsLoading = true+UseLazyLoading = falseinToListWithProjectionAsync(RedbQueryable,TreeQueryableBase).RedbProjectedQueryablenow always setsskipProps = true.
Changed
QueryContext.SkipPropsLoadingnow also controls lazy loader assignment — prevents both eager and lazy Props post-processing during projections.- Multi-target NuGet packages:
net8.0,net9.0,net10.0. - CLI: trial limit 1,024 requests per app launch (resets on restart).
1.2.13
Fixed
- Pro
DeleteAsyncuses PVT builders instead of facet functions. - ChangeTracking: handle existing nested
RedbObjectreferences correctly. - Multiple bug fixes for open-source (FREE) version.
Added
- Tree API for hierarchical queries.
- Window functions support in LINQ queries.
- Domain-isolated caches:
GlobalMetadataCache,GlobalListCache,GlobalPropsCache.
Changed
- Unified Pro feature exceptions.
- Query pipeline improvements.