Релизы redb
Подписаться на релизыВсе релизы redb, от свежего к старому. Текст — как в репозитории, без пересказа.
3.7.1
Why 3.7.1, and what happened to 3.7.0. 3.7.0 is withdrawn: it was built on .NET 9 and carries known vulnerabilities in its dependencies (see Security below). Every 3.7.0 package is unlisted on nuget.org and the
v3.7.0releases were deleted from the public mirrors. An unlisted version still installs by exact number — but there is no reason to: 3.7.1 replaces it completely.A patch, not a minor: the public API surface does not change. Adding
net10.0to the target list breaks nothing for existing consumers, andnet8.0/net9.0are kept.
Changed — the build moved to .NET 10
The applications and artifacts were built on net9 while the core and redb.Route had long
multi-targeted net8.0;net9.0;net10.0. The gap surfaced on 3.7.0: images and archives shipped as net9.
The redb.Tsak.* and redb.Identity.* libraries now declare net8.0;net9.0;net10.0 — exactly like
the core and Route, so the whole ecosystem is uniform. Host applications and tests are pinned to a
single net10.0. Images, archives and tags are -net10.
.NET 8 and .NET 9 both reach end of support on 10 November 2026 — the same day, Microsoft aligned
the STS 9 date with LTS 8. .NET 10 is supported until 14 November 2028. net8.0 and net9.0
remain in the libraries' target list for now.
Security — six high-severity advisories
Found while moving to .NET 10: changing the TFM forced a from-scratch rebuild and the NuGet audit spoke up. It stays silent on an incremental build, which is how all of this reached 3.7.0.
redb.Export—SQLitePCLRaw.lib.e_sqlite32.1.10 (GHSA-2m69-gcr7-jv3q, high), pulled in transitively throughMicrosoft.Data.Sqlite9.0.3.redb.SQLitehad pinned its way out of this long ago;redb.Exportsat on the olderMicrosoft.Data.Sqliteand slipped past that guard. Bumped to 10.0.0 plus the same explicitlib.e_sqlite33.50.3 pin.redb.CLInow requires .NET 10. It used to targetnet8.0and install on any .NET 8 or newer runtime. Roll-forward only moves up, never down, so a machine carrying only .NET 8 or .NET 9 can no longer install the tool — install .NET 10 there.
3.7.1
Why 3.7.1, and what happened to 3.7.0. 3.7.0 is withdrawn: it was built on .NET 9 and carries known vulnerabilities in its dependencies (see Security below). Every 3.7.0 package is unlisted on nuget.org and the
v3.7.0releases were deleted from the public mirrors. An unlisted version still installs by exact number — but there is no reason to: 3.7.1 replaces it completely.A patch, not a minor: the public API surface does not change. Adding
net10.0to the target list breaks nothing for existing consumers, andnet8.0/net9.0are kept.
Changed — the build moved to .NET 10
The applications and artifacts were built on net9 while the core and redb.Route had long
multi-targeted net8.0;net9.0;net10.0. The gap surfaced on 3.7.0: images and archives shipped as net9.
The redb.Tsak.* and redb.Identity.* libraries now declare net8.0;net9.0;net10.0 — exactly like
the core and Route, so the whole ecosystem is uniform. Host applications and tests are pinned to a
single net10.0. Images, archives and tags are -net10.
.NET 8 and .NET 9 both reach end of support on 10 November 2026 — the same day, Microsoft aligned
the STS 9 date with LTS 8. .NET 10 is supported until 14 November 2028. net8.0 and net9.0
remain in the libraries' target list for now.
Security — six high-severity advisories
Found while moving to .NET 10: changing the TFM forced a from-scratch rebuild and the NuGet audit spoke up. It stays silent on an incremental build, which is how all of this reached 3.7.0.
redb.Export—SQLitePCLRaw.lib.e_sqlite32.1.10 (GHSA-2m69-gcr7-jv3q, high), pulled in transitively throughMicrosoft.Data.Sqlite9.0.3.redb.SQLitehad pinned its way out of this long ago;redb.Exportsat on the olderMicrosoft.Data.Sqliteand slipped past that guard. Bumped to 10.0.0 plus the same explicitlib.e_sqlite33.50.3 pin.redb.CLInow requires .NET 10. It used to targetnet8.0and install on any .NET 8 or newer runtime. Roll-forward only moves up, never down, so a machine carrying only .NET 8 or .NET 9 can no longer install the tool — install .NET 10 there.
3.7.0
Why 3.7.0 and not 3.6.1. New public surface lands across the ecosystem — two new packages (
redb.Route.Soap,redb.Identity.Grpc, plusredb.Identity.Managementsplit out of the HTTP facade) and new API inredb.Route— and new surface cannot ship as a patch. The core packages carry mostly fixes, but the ecosystem moves on one number.
ExpressionSqlCacheandCompiledQueryare gone fromRedBase.Core. That is a public API removal, which strict SemVer would put in a major. It ships as a minor deliberately: both types were verified dead four ways before removal (see Removed below), nothing in REDB referenced them, and a major would flipLicensePolicy.FreeThroughMajorand start charging for Pro. A consumer who did reference either type directly has to drop that reference.
Fixed
A property changing type could silently lose its values (all providers, Free and Pro). Scheme synchronisation migrates a structure's stored values when the CLR property's type changes, then switches
_structures._id_type. The migration was called with the old type's numeric id passed into a lookup keyed by name, so it matched nothing, fell back to the literal"unknown", and every provider answered "unknown source type". That answer was then discarded and the type switched anyway — leaving the values in the old column, reading asnull, and physically deleted by the next save under the defaultDeleteInsertstrategy.Four independent layers had to line up for this to stay quiet: an id used as a name, a
?? "unknown"swallowing the miss, providers reporting failure as data rather than raising, and the caller throwing the result away. Each is now closed: the type is resolved by id, a missing type raises, the result is inspected, and_id_typeis changed only after a migration that actually completed.A migration that cannot complete now raises
RedbTypeMigrationExceptionand stops synchronisation. That is deliberate and is the whole point: the alternative outcomes are a structure whose values read as missing, or a CLR class and a structure that quietly disagree. The exception carries the scheme, property, both type names, how many values moved and how many did not, and the SQL to migrate by hand. A structure with no stored values is not affected — there is nothing to strand, so those type changes still pass.On SQLite this bug also disabled the provider's own safety net: its refusal to move values across storage columns was never reached, because the resolution failed one step earlier.
SQLite refused every cross-column type migration. PostgreSQL and MSSQL convert between scalar types; SQLite rejected all of them wholesale — a stopgap from the fix for the missing
migrate_structure_typefunction (GitHub #5) that swept up conversions which cannot fail.booltointis the plain case:_Booleanand_Longare bothINTEGER, so the value is copied as is.SQLite now implements the same matrix PostgreSQL does, for scalars: numeric and boolean moves, any scalar to text, and the temporal pairs (
_DateTimeOffsetholds a UTC Julian day precisely so SQLite's ownstrftime/juliandayread it). Text sources are guarded per row rather than cast blindly — SQLite has affinity, not types, andCAST('abc' AS INTEGER)is0, not an error — so an unreadable value stays where it is and is reported, which is what PostgreSQL's regex predicate does. Reference columns (_ListItem,_Object) stay refused: an FK cannot be produced from a scalar.SQLite also reports a refusal as a result now instead of throwing
NotSupportedException, matching the other two providers; the scheme-sync path turns it into the sameRedbTypeMigrationExceptioneverywhere.StringtoBooleanmigration destroyed values it could not read (PostgreSQL, MSSQL). The conversion mapped an unrecognised token toNULLthrough aCASEwhile the same statement cleared_String. The value was gone, and — because success is counted as rows updated — it was reported as a success: no error, no count, nothing to notice. Every neighbouring text conversion was already guarded (~ '^-?[0-9]+$'on PostgreSQL,TRY_CAST(...) IS NOT NULLon MSSQL); this one was not, on both providers. It is now predicated on the accepted token list, so anything else stays where it is and lands inerror_count.PostgreSQL's
StringtoDateTimeOffsetandStringtoGuidwere bare casts with no guard, so a single unparseable row raised and aborted the whole migration — every good value blocked by one bad one, and the failure arriving as a raw SQL error rather than a report. Both are now guarded, which is what MSSQL already did.migrate_structure_typenow ships in the versioned module, so fixes to it reach existing databases. It lived insql/, which lands only inredb_init.sql— applied when the tables are absent, i.e. to fresh databases only. Any correction to it was therefore unreachable for every database already in use. Moved tosql/v2-pvt/27_migrate_structure_type.sql, so the module version check redeploys it automatically on the next start like the rest of the module. PostgreSQLpvt_module_version0.6.5 → 0.6.6, MSSQL 0.1.6 → 0.1.7.
Added
Covering index on
_objects(_id_parent)carrying_hash— now on all three providers (RedBase.Postgres,RedBase.SQLite). MSSQL has hadIX__objects__id_parentwithINCLUDE (_id, _hash, _id_scheme)for some time; PostgreSQL and SQLite had no equivalent. Their nearest index,IX__objects__parent_scheme_id, stops at(_id_parent, _id_scheme, _id)and does not carry_hash, so a tree walk that reads the object hash — which is what the transparent cache does — left the index for the heap on every row.PostgreSQL uses
INCLUDE, keeping_hashout of the key since it is returned rather than searched by. SQLite has noINCLUDE, so the columns are folded into the key, matching the indexes already written that way in that file. The index name is the same on all three.Verified on live engines rather than taken from documentation: PostgreSQL 18.1 plans
SELECT _id, _hash, _id_scheme WHERE _id_parent = ?asIndex Only Scan, SQLite 3.46.1 asSEARCH ... USING COVERING INDEX.New databases pick this up from the schema script. Existing ones do not: the initialisation script is applied only when the tables are absent, so the index has to be created by hand there.
PVT prefilter — a cutting step before the pivot aggregate (
RedBase.Core.Pro+ all three Pro providers). A filter over Props compiled into a condition sitting aboveGROUP BY, so by the time it was evaluated_valueshad already been read and folded for every object in the scheme. No index could apply: the predicate filtered the result of an aggregate, not a column. The practical consequence was that query cost did not depend on selectivity at all — searching for one rare order number cost exactly as much as an empty search, and the trigram GIN index on_Stringsat unused.The prefilter narrows the object set before the aggregate runs. It is built as a superset: it may let extra objects through, it may never lose one, and the authoritative filter stays where it was. Anything the planner cannot analyse yields no prefilter and today's behaviour exactly, so the worst outcome is the absence of a speedup rather than a change of results.
Opt-in through
RedbServiceConfiguration.EnablePvtPrefilter, off by default.Measured on all three engines seeded to the same size, 100 000 objects and roughly 8.4M rows in
_values, statistics refreshed, server-side time, best of three:query PG before PG after SQLite before SQLite after MSSql before MSSql after one needle across two string fields, as the provider emits it 184.6 ms 100.4 ms 6 ms 6 ms (rule below) 55 ms 17 ms the same, with an ORDER BY188.3 ms 90.8 ms 1150 ms 311 ms 55 ms 17 ms the same, whole result, no paging 337.9 ms 156.4 ms 1201 ms 314 ms 7037 ms 1327 ms date range, 1.25% selective, paged 153.7 ms 1.5 ms 17 ms under 1 ms 2 ms 2 ms date range, whole result 88 ms 65 ms The three engines win for three different reasons, which is worth knowing before predicting numbers on your own data. PostgreSQL engages the trigram GIN and reads far fewer rows: the string index returns 40 958 rows where the structure index returns 200 000. SQLite engages a covering partial index and stops going back to the table. MSSql reads the same pages and saves CPU instead, because
_StringisNVARCHAR(MAX)and the comparison carries an explicit collation. The date range shows the spread plainly: a hundredfold gain on PostgreSQL, sixteenfold on SQLite, nothing at all on SQL Server, which already streamed by_id_objectand stopped at the hundredth group.An earlier tree measurement, taken on a separate PostgreSQL seed of 99 200 nodes six levels deep, gave 933.5 ms against 362.7 ms for the same needle.
Scope of what actually gets a prefilter today: a top-level
ORover selective leaves, and a range or equality on a single field. A top-levelANDacross different fields does not, because the row-level form can only express a disjunction and the guard against nulling out uncovered pivot columns then suppresses it. Filters touching arrays, dictionaries,== null, cross-field comparisons or computed expressions are recognised as unanalysable and produce no prefilter.Two guards, not one. The row form drops rows, not objects, so having a branch is not the same as keeping a value: a branch is a predicate, and a row failing its own branch is dropped like any other. With several branches an object can qualify through branch A while its branch-B row is thrown away, leaving column B NULL. The object set survives, which is why this went unnoticed by 1606 existing tests, but every value read from column B is then a lie. So on top of the coverage check a multi-branch plan is emitted only when nothing outside the disjunction reads those columns: no
OrderByorDistinctByover Props, no projection, noDistinct. A single-branch plan needs no such guard, since an object survives only if that very row matched. For the same reason a disjunction nested inside a conjunction is never taken as a candidate: the sibling conjunct would read a column the prefilter had already nulled out, and there the objects are lost outright rather than merely misordered.
Fixed
Case-insensitive search folded ASCII only, so it did not work for most of the world's text (
RedBase.Core,RedBase.Core.Pro, all three providers).Contains(needle, OrdinalIgnoreCase)foundHELLObut notПРИВЕТ, and the same held for Greek, Hungarian, Polish, Czech and French. Case folding comes from the database's own rules: on SQLite that is ASCII-only unconditionally (LIKE,lower(),upper()and evenCOLLATE NOCASE), on PostgreSQL whenever the database was created withLC_CTYPE=C, and never on SQL Server, whose default collation already folds every script. New opt-in settingRedbServiceConfiguration.StringCollationfixes every script whose case mapping is one character to one character, in one place, with no per-language work. It covers the whole family together —ContainsIgnoreCase,StartsWithIgnoreCase,EndsWithIgnoreCase,ToLower,ToUpperand the case-insensitive regex — so a search can never disagree with a comparison. Implemented per provider because the providers differ in kind: PostgreSQL attachesCOLLATEto the folded operand (Pro in C#, Free through the newpvt_fold_case()reading aredb.string_collationGUC, which needed no change to any function signature); SQLite has nothing to attach a collation to, so it replaces the built-inlike,lowerandupperwith Unicode-aware ones on the connection, the same technique SQLite's own ICU extension uses and with no native rebuild; SQL Server needs nothing. Unset, the generated SQL is byte for byte what it was. Two caveats that are documented rather than hidden: on PostgreSQL a collated operand cannot use an index built with the database's collation, so a trigram search degrades to a full scan until a matching expression index is created (the DDL is in COLLATION.md, and RedBase does not create it for you); and diacritics, Germanß/SSand the Turkish dottedİare not case folding and are not fixed — what the last two do differs per provider, which the test suite now pins per provider rather than assuming. See COLLATION.md.Pro discarded the configured SQL dialect (
RedBase.Postgres.Pro).ProRedbServiceresolved bothISqlDialectandISqlDialectProfrom the container but handed only the base one toProQueryableProvider;ProQueryProviderthen narrowed it withas ProPostgreSqlDialect, a base instance failed that cast, and the fallback silently constructednew ProPostgreSqlDialect()with no configuration at all. Latent for as long as the dialect carried no settings —StringCollationwas the first, and it vanished on every Pro query while working correctly on Free. The Pro dialect is now passed through explicitly and preferred over the base one wherever both are available.Temporal semantics:
DateTimekeeps its clock reading on every read path, andDateOnlyworks at all (RedBase.Core,RedBase.Core.Pro, all three providers). In REDB aDateTimecarries no time zone: 14:00 written is 14:00 read, on any host. Object materialization honoured that; the analytics path did not.JsonValueConverterparsed a zoned ISO string with the defaultDateTimeStyles, which converts it into the caller's local zone, soMinRedbAsync,GroupBy,Windowand scalar projections answered with a different value than the object did for the same stored field. Separately,DateOnlynever round-tripped on any provider: it was seeded with_db_type = 'DateTime', a value noget_object_jsonbranch knows about, so the column was written correctly and dropped on the way out, and everyDateOnlyproperty materialized as0001-01-01. Retyped toDateTimeOffset, which routes it through the branch that already exists everywhere: no SQL function changed, no PVT version bump, no native SQLite rebuild. Migrations for existing databases:redb.{Postgres,MSSql,SQLite}/sql/migrate_dateonly_db_type.sql. Also fixed:DateOnly,TimeOnlyandTimeSpanwent into_values._Stringthrough the current culture's short pattern and were parsed back the same way, so a row written under ru-RU stopped loading under en-US; theTimeSpanJSON form dropped the day component and the sign (3.02:00:00came back as02:00:00); and the filter needed the same invariant spelling in both the Free (FacetFilterBuilder) and Pro (SqlParameterCollector) paths, or a saved row was unfindable by its own value. All temporal text now goes through a singleRedbTemporalFormat. An unassignedDateOnlyalso threw on load, because Npgsql writesDateTime.MinValueas-infinityand only theDateTimeconverters recognised that marker. See DATETIME.md for the contract, the precision table and the boundaries left open.Phantom
_date_deletebase field (RedBase.Core,RedBase.Core.Pro).BaseFieldMapperandProSqlBuilderBaseclaimedDateDeleteas a base field and mapped it to_date_delete, a column present in none of the three DDLs and a leftover of the removed_deleted_objectstable. Typed LINQ could not reach it, but a string-addressed query passed validation and compiled SQL against a column that is not there.Ambiguous
_idwhen a== nullfilter met a filter on the baseId(all three Pro providers). A props null check switches the PVT CTE into a form whoseFROMcarries both_objectsand_values, while the base-field filter was compiled without a table alias._idis the only column present in both tables, so the combination producedcolumn reference "_id" is ambiguouson PostgreSQL and its equivalents elsewhere. Every_objectssubquery emitted by the pivot generator now carries theo_srcalias and the compiled filter is qualified against it.Reachable from ordinary code:
.Where(p => p.Field == null).WhereRedb(o => ids.Contains(o.Id)). Only_idwas affected;_id_parent,_hash,_value_*and the rest do not collide.ChangePasswordAsyncthrewUnauthorizedAccessExceptionon a wrong current password instead of returningfalse(RedBase.Core, GitHub #4). The method isTask<bool>documented as "true if changed", so a change-password form built against the contract 500'd on the most common user error. A wrong current password now returnsfalse; precondition failures (null args, disabled/system user) still throw. Regression test on all six Free/Pro fixtures.SQLite: any property type change crashed
InitializeAsyncwithno such table: migrate_structure_type(RedBase.SQLite, GitHub #5). The scheme-sync path runsSELECT * FROM migrate_structure_type(...), a stored function that only exists on PostgreSQL/SQL Server — SQLite has none, and itsredb.SQLite/sql/migrate_structure_type.sqlis a dead PostgreSQL copy.SqliteSchemeSyncProvidernow performs the migration in C#: a type change that keeps the same_valuesstorage column (e.g.Int→Long,DateTime→DateOnly) is a clean no-op, and a cross-column change that actually holds data raises a clear, actionable error instead of the cryptic missing-table one. Regression tests on Free and Pro SQLite.Docs & packaging notes from a first SQLite + Pro integration (GitHub #6). (1) The
EnablePropsCacheXML summary claimed it "works only whenEnableLazyLoadingForProps = true" — false; the cache is independent of lazy loading (the code gates on neither), and the summary now says so. (2)redb.CLIREADME's Supported Providers table omitted SQLite though--provider sqliteworks; it is now listed. (3)redb.CLItargetsnet8.0only (deliberately, to avoid tripling the native SQLite payload) but lacked roll-forward, so a host with only a newer major runtime failed to start it;RollForward=LatestMajornow lets the net8.0 tool run on net9/net10-only machines.
Removed
ExpressionSqlCacheandCompiledQuery(RedBase.Core). Both were public types that nothing ever used: an SQL-template cache that was never wired into any query path, and the record it returned. Verified dead four ways before removal — a repository-wide search found references only in its own file, an architecture plan underdocs/and two documentation pages; the only assembly scanning in the codebase looks for types carrying[RedbScheme];CompiledQueryhad no consumer besides the cache; and the full solution builds with both files gone. This is a public API removal, so a consumer who referenced either type directly (nothing in REDB did) needs to drop that reference. The architecture pages on the documentation site described this cache as aConcurrentDictionarykeyed by expression string and shared for the lifetime of anIRedbService. It was none of those things: the implementation was a staticMemoryCachesingleton with TTL and eviction, keyed by expression structure with constants stripped. That block is gone: nothing caches in the SQL-compilation layer any more, and the caches that do exist keep their own section further down the same page.
Changed
The prefilter steps aside on SQLite when a limit meets no ordering (
RedBase.SQLite.Pro). Without a prefilter SQLite walksIX__values__object_structure_lookup, so rows reachGROUP BYalready ordered by_id_object, the aggregate streams, andLIMITstops after the Nth group. A multi-branch prefilter makes the planner switch to MULTI-INDEX OR overIX__values__String_not_null: the rows now arrive ordered by structure and value,GROUP BYneedsUSE TEMP B-TREE, everything is materialised and the limit saves nothing.Measured on 100 000 objects and 8.4M rows in
_values, one needle across two string fields, seven interleaved repetitions of each form after warming both: 8-12 ms without the prefilter against 388-521 ms with it. The ranges do not overlap, and the plans differ structurally, so this is not timing noise.The rule is narrow by construction: only SQLite, only a plan with more than one branch, only a query that carries a limit and no ordering at all. Add any
ORDER BYand the aggregate is materialised either way, so the prefilter wins again, 1150 ms against 311 ms on the same data. A single-branch plan never triggers MULTI-INDEX OR and keeps its own win, 16x on a date range.Neither of the other two engines needs this. PostgreSQL materialises the aggregate regardless and gains 1.8x to 2.2x on all three shapes. SQL Server cannot even produce the offending shape: its
OFFSET/FETCHrequires anORDER BY, so every paged query carries one, and the prefilter gains there too, 3x paged and 5.3x on the full result. The rule therefore lives in the SQLite provider (SqlitePrefilterGuards) and not in the shared planner, which stays dialect-agnostic.The pivot scan no longer re-checks the scheme (all three Pro providers). Every PVT CTE carried
AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = @p0), and tree queries additionally joined_objects ooonly to stateoo._id_scheme = @p0. Both were redundant:_valuesrows are selected by_id_structure, a structure belongs to exactly one scheme, and the outerSELECT ... WHERE o._id_scheme = @p0re-applies the check anyway. The subquery and the join are now dropped whenever the outer statement carries that filter itself.Two callers must keep the check and say so explicitly.
ExecuteDeleteAsyncbuilds aDELETEwhose only scheme filter is the one inside the CTE, so it passesschemeCheckRequired: true; without it a soft-deleted object, which moves to scheme-10while its_valueskeep their structures, would be matched and hard-deleted. The other is a filter attached directly to the object set. Tree queries have no delete path today; a future one must carry its ownWHERE _id_scheme.Verified by comparing sorted id sets before and after on all three engines, flat and tree: identical everywhere. On SQLite the flat form also ran 28% faster (1209 ms → 865 ms on 100 100 objects).
The string value index in SQLite lost its length guard (
RedBase.SQLite).IX__values__String_not_nullcarriedAND length(_String) < 2000alongsideIS NOT NULL. That guard belongs to PostgreSQL, where a btree key over roughly 2700 bytes overflows the page; SQLite has no such limit and the condition had been copied across. Its effect there was to make the index unusable: SQLite does not prove implications between a query and a partial index predicate, it looks for a matching term, and no query stateslength(_String) < 2000.Upgrade note.
CREATE INDEX IF NOT EXISTSdoes not replace an existing index, so databases created before this change keep the guarded version and see no benefit. Recreate it once:DROP INDEX IF EXISTS "IX__values__String_not_null"; CREATE INDEX "IX__values__String_not_null" ON _values (_id_structure, _id_object, _String) WHERE _String IS NOT NULL;
3.7.0
Why 3.7.0 and not 3.6.1. New public surface lands across the ecosystem — two new packages (
redb.Route.Soap,redb.Identity.Grpc, plusredb.Identity.Managementsplit out of the HTTP facade) and new API inredb.Route— and new surface cannot ship as a patch. The core packages carry mostly fixes, but the ecosystem moves on one number.
ExpressionSqlCacheandCompiledQueryare gone fromRedBase.Core. That is a public API removal, which strict SemVer would put in a major. It ships as a minor deliberately: both types were verified dead four ways before removal (see Removed below), nothing in REDB referenced them, and a major would flipLicensePolicy.FreeThroughMajorand start charging for Pro. A consumer who did reference either type directly has to drop that reference.
Fixed
A property changing type could silently lose its values (all providers, Free and Pro). Scheme synchronisation migrates a structure's stored values when the CLR property's type changes, then switches
_structures._id_type. The migration was called with the old type's numeric id passed into a lookup keyed by name, so it matched nothing, fell back to the literal"unknown", and every provider answered "unknown source type". That answer was then discarded and the type switched anyway — leaving the values in the old column, reading asnull, and physically deleted by the next save under the defaultDeleteInsertstrategy.Four independent layers had to line up for this to stay quiet: an id used as a name, a
?? "unknown"swallowing the miss, providers reporting failure as data rather than raising, and the caller throwing the result away. Each is now closed: the type is resolved by id, a missing type raises, the result is inspected, and_id_typeis changed only after a migration that actually completed.A migration that cannot complete now raises
RedbTypeMigrationExceptionand stops synchronisation. That is deliberate and is the whole point: the alternative outcomes are a structure whose values read as missing, or a CLR class and a structure that quietly disagree. The exception carries the scheme, property, both type names, how many values moved and how many did not, and the SQL to migrate by hand. A structure with no stored values is not affected — there is nothing to strand, so those type changes still pass.On SQLite this bug also disabled the provider's own safety net: its refusal to move values across storage columns was never reached, because the resolution failed one step earlier.
SQLite refused every cross-column type migration. PostgreSQL and MSSQL convert between scalar types; SQLite rejected all of them wholesale — a stopgap from the fix for the missing
migrate_structure_typefunction (GitHub #5) that swept up conversions which cannot fail.booltointis the plain case:_Booleanand_Longare bothINTEGER, so the value is copied as is.SQLite now implements the same matrix PostgreSQL does, for scalars: numeric and boolean moves, any scalar to text, and the temporal pairs (
_DateTimeOffsetholds a UTC Julian day precisely so SQLite's ownstrftime/juliandayread it). Text sources are guarded per row rather than cast blindly — SQLite has affinity, not types, andCAST('abc' AS INTEGER)is0, not an error — so an unreadable value stays where it is and is reported, which is what PostgreSQL's regex predicate does. Reference columns (_ListItem,_Object) stay refused: an FK cannot be produced from a scalar.SQLite also reports a refusal as a result now instead of throwing
NotSupportedException, matching the other two providers; the scheme-sync path turns it into the sameRedbTypeMigrationExceptioneverywhere.StringtoBooleanmigration destroyed values it could not read (PostgreSQL, MSSQL). The conversion mapped an unrecognised token toNULLthrough aCASEwhile the same statement cleared_String. The value was gone, and — because success is counted as rows updated — it was reported as a success: no error, no count, nothing to notice. Every neighbouring text conversion was already guarded (~ '^-?[0-9]+$'on PostgreSQL,TRY_CAST(...) IS NOT NULLon MSSQL); this one was not, on both providers. It is now predicated on the accepted token list, so anything else stays where it is and lands inerror_count.PostgreSQL's
StringtoDateTimeOffsetandStringtoGuidwere bare casts with no guard, so a single unparseable row raised and aborted the whole migration — every good value blocked by one bad one, and the failure arriving as a raw SQL error rather than a report. Both are now guarded, which is what MSSQL already did.migrate_structure_typenow ships in the versioned module, so fixes to it reach existing databases. It lived insql/, which lands only inredb_init.sql— applied when the tables are absent, i.e. to fresh databases only. Any correction to it was therefore unreachable for every database already in use. Moved tosql/v2-pvt/27_migrate_structure_type.sql, so the module version check redeploys it automatically on the next start like the rest of the module. PostgreSQLpvt_module_version0.6.5 → 0.6.6, MSSQL 0.1.6 → 0.1.7.
Added
Covering index on
_objects(_id_parent)carrying_hash— now on all three providers (RedBase.Postgres,RedBase.SQLite). MSSQL has hadIX__objects__id_parentwithINCLUDE (_id, _hash, _id_scheme)for some time; PostgreSQL and SQLite had no equivalent. Their nearest index,IX__objects__parent_scheme_id, stops at(_id_parent, _id_scheme, _id)and does not carry_hash, so a tree walk that reads the object hash — which is what the transparent cache does — left the index for the heap on every row.PostgreSQL uses
INCLUDE, keeping_hashout of the key since it is returned rather than searched by. SQLite has noINCLUDE, so the columns are folded into the key, matching the indexes already written that way in that file. The index name is the same on all three.Verified on live engines rather than taken from documentation: PostgreSQL 18.1 plans
SELECT _id, _hash, _id_scheme WHERE _id_parent = ?asIndex Only Scan, SQLite 3.46.1 asSEARCH ... USING COVERING INDEX.New databases pick this up from the schema script. Existing ones do not: the initialisation script is applied only when the tables are absent, so the index has to be created by hand there.
PVT prefilter — a cutting step before the pivot aggregate (
RedBase.Core.Pro+ all three Pro providers). A filter over Props compiled into a condition sitting aboveGROUP BY, so by the time it was evaluated_valueshad already been read and folded for every object in the scheme. No index could apply: the predicate filtered the result of an aggregate, not a column. The practical consequence was that query cost did not depend on selectivity at all — searching for one rare order number cost exactly as much as an empty search, and the trigram GIN index on_Stringsat unused.The prefilter narrows the object set before the aggregate runs. It is built as a superset: it may let extra objects through, it may never lose one, and the authoritative filter stays where it was. Anything the planner cannot analyse yields no prefilter and today's behaviour exactly, so the worst outcome is the absence of a speedup rather than a change of results.
Opt-in through
RedbServiceConfiguration.EnablePvtPrefilter, off by default.Measured on all three engines seeded to the same size, 100 000 objects and roughly 8.4M rows in
_values, statistics refreshed, server-side time, best of three:query PG before PG after SQLite before SQLite after MSSql before MSSql after one needle across two string fields, as the provider emits it 184.6 ms 100.4 ms 6 ms 6 ms (rule below) 55 ms 17 ms the same, with an ORDER BY188.3 ms 90.8 ms 1150 ms 311 ms 55 ms 17 ms the same, whole result, no paging 337.9 ms 156.4 ms 1201 ms 314 ms 7037 ms 1327 ms date range, 1.25% selective, paged 153.7 ms 1.5 ms 17 ms under 1 ms 2 ms 2 ms date range, whole result 88 ms 65 ms The three engines win for three different reasons, which is worth knowing before predicting numbers on your own data. PostgreSQL engages the trigram GIN and reads far fewer rows: the string index returns 40 958 rows where the structure index returns 200 000. SQLite engages a covering partial index and stops going back to the table. MSSql reads the same pages and saves CPU instead, because
_StringisNVARCHAR(MAX)and the comparison carries an explicit collation. The date range shows the spread plainly: a hundredfold gain on PostgreSQL, sixteenfold on SQLite, nothing at all on SQL Server, which already streamed by_id_objectand stopped at the hundredth group.An earlier tree measurement, taken on a separate PostgreSQL seed of 99 200 nodes six levels deep, gave 933.5 ms against 362.7 ms for the same needle.
Scope of what actually gets a prefilter today: a top-level
ORover selective leaves, and a range or equality on a single field. A top-levelANDacross different fields does not, because the row-level form can only express a disjunction and the guard against nulling out uncovered pivot columns then suppresses it. Filters touching arrays, dictionaries,== null, cross-field comparisons or computed expressions are recognised as unanalysable and produce no prefilter.Two guards, not one. The row form drops rows, not objects, so having a branch is not the same as keeping a value: a branch is a predicate, and a row failing its own branch is dropped like any other. With several branches an object can qualify through branch A while its branch-B row is thrown away, leaving column B NULL. The object set survives, which is why this went unnoticed by 1606 existing tests, but every value read from column B is then a lie. So on top of the coverage check a multi-branch plan is emitted only when nothing outside the disjunction reads those columns: no
OrderByorDistinctByover Props, no projection, noDistinct. A single-branch plan needs no such guard, since an object survives only if that very row matched. For the same reason a disjunction nested inside a conjunction is never taken as a candidate: the sibling conjunct would read a column the prefilter had already nulled out, and there the objects are lost outright rather than merely misordered.
Fixed
Case-insensitive search folded ASCII only, so it did not work for most of the world's text (
RedBase.Core,RedBase.Core.Pro, all three providers).Contains(needle, OrdinalIgnoreCase)foundHELLObut notПРИВЕТ, and the same held for Greek, Hungarian, Polish, Czech and French. Case folding comes from the database's own rules: on SQLite that is ASCII-only unconditionally (LIKE,lower(),upper()and evenCOLLATE NOCASE), on PostgreSQL whenever the database was created withLC_CTYPE=C, and never on SQL Server, whose default collation already folds every script. New opt-in settingRedbServiceConfiguration.StringCollationfixes every script whose case mapping is one character to one character, in one place, with no per-language work. It covers the whole family together —ContainsIgnoreCase,StartsWithIgnoreCase,EndsWithIgnoreCase,ToLower,ToUpperand the case-insensitive regex — so a search can never disagree with a comparison. Implemented per provider because the providers differ in kind: PostgreSQL attachesCOLLATEto the folded operand (Pro in C#, Free through the newpvt_fold_case()reading aredb.string_collationGUC, which needed no change to any function signature); SQLite has nothing to attach a collation to, so it replaces the built-inlike,lowerandupperwith Unicode-aware ones on the connection, the same technique SQLite's own ICU extension uses and with no native rebuild; SQL Server needs nothing. Unset, the generated SQL is byte for byte what it was. Two caveats that are documented rather than hidden: on PostgreSQL a collated operand cannot use an index built with the database's collation, so a trigram search degrades to a full scan until a matching expression index is created (the DDL is in COLLATION.md, and RedBase does not create it for you); and diacritics, Germanß/SSand the Turkish dottedİare not case folding and are not fixed — what the last two do differs per provider, which the test suite now pins per provider rather than assuming. See COLLATION.md.Pro discarded the configured SQL dialect (
RedBase.Postgres.Pro).ProRedbServiceresolved bothISqlDialectandISqlDialectProfrom the container but handed only the base one toProQueryableProvider;ProQueryProviderthen narrowed it withas ProPostgreSqlDialect, a base instance failed that cast, and the fallback silently constructednew ProPostgreSqlDialect()with no configuration at all. Latent for as long as the dialect carried no settings —StringCollationwas the first, and it vanished on every Pro query while working correctly on Free. The Pro dialect is now passed through explicitly and preferred over the base one wherever both are available.Temporal semantics:
DateTimekeeps its clock reading on every read path, andDateOnlyworks at all (RedBase.Core,RedBase.Core.Pro, all three providers). In REDB aDateTimecarries no time zone: 14:00 written is 14:00 read, on any host. Object materialization honoured that; the analytics path did not.JsonValueConverterparsed a zoned ISO string with the defaultDateTimeStyles, which converts it into the caller's local zone, soMinRedbAsync,GroupBy,Windowand scalar projections answered with a different value than the object did for the same stored field. Separately,DateOnlynever round-tripped on any provider: it was seeded with_db_type = 'DateTime', a value noget_object_jsonbranch knows about, so the column was written correctly and dropped on the way out, and everyDateOnlyproperty materialized as0001-01-01. Retyped toDateTimeOffset, which routes it through the branch that already exists everywhere: no SQL function changed, no PVT version bump, no native SQLite rebuild. Migrations for existing databases:redb.{Postgres,MSSql,SQLite}/sql/migrate_dateonly_db_type.sql. Also fixed:DateOnly,TimeOnlyandTimeSpanwent into_values._Stringthrough the current culture's short pattern and were parsed back the same way, so a row written under ru-RU stopped loading under en-US; theTimeSpanJSON form dropped the day component and the sign (3.02:00:00came back as02:00:00); and the filter needed the same invariant spelling in both the Free (FacetFilterBuilder) and Pro (SqlParameterCollector) paths, or a saved row was unfindable by its own value. All temporal text now goes through a singleRedbTemporalFormat. An unassignedDateOnlyalso threw on load, because Npgsql writesDateTime.MinValueas-infinityand only theDateTimeconverters recognised that marker. See DATETIME.md for the contract, the precision table and the boundaries left open.Phantom
_date_deletebase field (RedBase.Core,RedBase.Core.Pro).BaseFieldMapperandProSqlBuilderBaseclaimedDateDeleteas a base field and mapped it to_date_delete, a column present in none of the three DDLs and a leftover of the removed_deleted_objectstable. Typed LINQ could not reach it, but a string-addressed query passed validation and compiled SQL against a column that is not there.Ambiguous
_idwhen a== nullfilter met a filter on the baseId(all three Pro providers). A props null check switches the PVT CTE into a form whoseFROMcarries both_objectsand_values, while the base-field filter was compiled without a table alias._idis the only column present in both tables, so the combination producedcolumn reference "_id" is ambiguouson PostgreSQL and its equivalents elsewhere. Every_objectssubquery emitted by the pivot generator now carries theo_srcalias and the compiled filter is qualified against it.Reachable from ordinary code:
.Where(p => p.Field == null).WhereRedb(o => ids.Contains(o.Id)). Only_idwas affected;_id_parent,_hash,_value_*and the rest do not collide.ChangePasswordAsyncthrewUnauthorizedAccessExceptionon a wrong current password instead of returningfalse(RedBase.Core, GitHub #4). The method isTask<bool>documented as "true if changed", so a change-password form built against the contract 500'd on the most common user error. A wrong current password now returnsfalse; precondition failures (null args, disabled/system user) still throw. Regression test on all six Free/Pro fixtures.SQLite: any property type change crashed
InitializeAsyncwithno such table: migrate_structure_type(RedBase.SQLite, GitHub #5). The scheme-sync path runsSELECT * FROM migrate_structure_type(...), a stored function that only exists on PostgreSQL/SQL Server — SQLite has none, and itsredb.SQLite/sql/migrate_structure_type.sqlis a dead PostgreSQL copy.SqliteSchemeSyncProvidernow performs the migration in C#: a type change that keeps the same_valuesstorage column (e.g.Int→Long,DateTime→DateOnly) is a clean no-op, and a cross-column change that actually holds data raises a clear, actionable error instead of the cryptic missing-table one. Regression tests on Free and Pro SQLite.Docs & packaging notes from a first SQLite + Pro integration (GitHub #6). (1) The
EnablePropsCacheXML summary claimed it "works only whenEnableLazyLoadingForProps = true" — false; the cache is independent of lazy loading (the code gates on neither), and the summary now says so. (2)redb.CLIREADME's Supported Providers table omitted SQLite though--provider sqliteworks; it is now listed. (3)redb.CLItargetsnet8.0only (deliberately, to avoid tripling the native SQLite payload) but lacked roll-forward, so a host with only a newer major runtime failed to start it;RollForward=LatestMajornow lets the net8.0 tool run on net9/net10-only machines.
Removed
ExpressionSqlCacheandCompiledQuery(RedBase.Core). Both were public types that nothing ever used: an SQL-template cache that was never wired into any query path, and the record it returned. Verified dead four ways before removal — a repository-wide search found references only in its own file, an architecture plan underdocs/and two documentation pages; the only assembly scanning in the codebase looks for types carrying[RedbScheme];CompiledQueryhad no consumer besides the cache; and the full solution builds with both files gone. This is a public API removal, so a consumer who referenced either type directly (nothing in REDB did) needs to drop that reference. The architecture pages on the documentation site described this cache as aConcurrentDictionarykeyed by expression string and shared for the lifetime of anIRedbService. It was none of those things: the implementation was a staticMemoryCachesingleton with TTL and eviction, keyed by expression structure with constants stripped. That block is gone: nothing caches in the SQL-compilation layer any more, and the caches that do exist keep their own section further down the same page.
Changed
The prefilter steps aside on SQLite when a limit meets no ordering (
RedBase.SQLite.Pro). Without a prefilter SQLite walksIX__values__object_structure_lookup, so rows reachGROUP BYalready ordered by_id_object, the aggregate streams, andLIMITstops after the Nth group. A multi-branch prefilter makes the planner switch to MULTI-INDEX OR overIX__values__String_not_null: the rows now arrive ordered by structure and value,GROUP BYneedsUSE TEMP B-TREE, everything is materialised and the limit saves nothing.Measured on 100 000 objects and 8.4M rows in
_values, one needle across two string fields, seven interleaved repetitions of each form after warming both: 8-12 ms without the prefilter against 388-521 ms with it. The ranges do not overlap, and the plans differ structurally, so this is not timing noise.The rule is narrow by construction: only SQLite, only a plan with more than one branch, only a query that carries a limit and no ordering at all. Add any
ORDER BYand the aggregate is materialised either way, so the prefilter wins again, 1150 ms against 311 ms on the same data. A single-branch plan never triggers MULTI-INDEX OR and keeps its own win, 16x on a date range.Neither of the other two engines needs this. PostgreSQL materialises the aggregate regardless and gains 1.8x to 2.2x on all three shapes. SQL Server cannot even produce the offending shape: its
OFFSET/FETCHrequires anORDER BY, so every paged query carries one, and the prefilter gains there too, 3x paged and 5.3x on the full result. The rule therefore lives in the SQLite provider (SqlitePrefilterGuards) and not in the shared planner, which stays dialect-agnostic.The pivot scan no longer re-checks the scheme (all three Pro providers). Every PVT CTE carried
AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = @p0), and tree queries additionally joined_objects ooonly to stateoo._id_scheme = @p0. Both were redundant:_valuesrows are selected by_id_structure, a structure belongs to exactly one scheme, and the outerSELECT ... WHERE o._id_scheme = @p0re-applies the check anyway. The subquery and the join are now dropped whenever the outer statement carries that filter itself.Two callers must keep the check and say so explicitly.
ExecuteDeleteAsyncbuilds aDELETEwhose only scheme filter is the one inside the CTE, so it passesschemeCheckRequired: true; without it a soft-deleted object, which moves to scheme-10while its_valueskeep their structures, would be matched and hard-deleted. The other is a filter attached directly to the object set. Tree queries have no delete path today; a future one must carry its ownWHERE _id_scheme.Verified by comparing sorted id sets before and after on all three engines, flat and tree: identical everywhere. On SQLite the flat form also ran 28% faster (1209 ms → 865 ms on 100 100 objects).
The string value index in SQLite lost its length guard (
RedBase.SQLite).IX__values__String_not_nullcarriedAND length(_String) < 2000alongsideIS NOT NULL. That guard belongs to PostgreSQL, where a btree key over roughly 2700 bytes overflows the page; SQLite has no such limit and the condition had been copied across. Its effect there was to make the index unusable: SQLite does not prove implications between a query and a partial index predicate, it looks for a matching term, and no query stateslength(_String) < 2000.Upgrade note.
CREATE INDEX IF NOT EXISTSdoes not replace an existing index, so databases created before this change keep the guarded version and see no benefit. Recreate it once:DROP INDEX IF EXISTS "IX__values__String_not_null"; CREATE INDEX "IX__values__String_not_null" ON _values (_id_structure, _id_object, _String) WHERE _String IS NOT NULL;
3.6.0
Why 3.6.0 and not 3.5.2.
redb.Routeadds public API in this release —.PropagateToolHeaders(...)and the matching?propagateToolHeaders=endpoint option — and new surface cannot ship as a patch. The core packages carry only fixes, but the ecosystem moves on one number, and a minor is allowed to contain nothing but fixes for the packages that got none.This also re-unifies the line: 3.5.1 covered
redb.Route,redb.Tsakandredb.Identitywhile the core stayed at 3.5.0. From 3.6.0 all four are on the same number again.The SQLite native extension was rebuilt for every RID. The tree-scope fix below changes
redb_pvt.c, so a package carrying the previous binaries would have shipped the fix on the managed side and left the Free tier leaking across trees — silently, with no error. win-x64, linux-x64 and linux-arm64 are all rebuilt from the current source and verified byte-different from 3.5.0. macOS still ships no extension (it needs a macOS runner).
Fixed
SumRedbAsync/AverageRedbAsyncthrewInvalidOperationExceptionon an empty selection (RedBase.Core, all providers). ASUM/AVGwith no matching rows isNULLin SQL (an aggregate withoutGROUP BYstill returns one row), and the result was read straight throughJsonElement.GetDecimal, which requires aNumberkind and throws onnull. This path became reachable only after 3.5.0 made base-field aggregations honour the filter: before thatWhereRedb(...)was dropped, so the aggregate always spanned the whole (non-empty) scheme and never producedNULL— the filter defect was masking this one. Both methods now treat aNULLresult as0m(an empty sum is 0 —Enumerable.Sumsemantics; a ledger with no movements balances to 0), matching theNULLguardMinRedbAsync/MaxRedbAsyncalready had. New empty-selection tests cover both across all six Free/Pro fixtures.TreeQuery(rootObj).WhereLeaves()/.WhereRoots()ignored the root and scanned the whole scheme (all six providers — Free and Pro). TheIsLeaf/IsRoottree branches built their query from_id_schemealone and droppedcontext.RootObjectId/ParentIds, so a root-scoped leaf/root query returned the leaves/roots of EVERY tree in the scheme. For tree-per-entity storage this leaks across trees — e.g.LoadPathAsync(leaf: null)picked the newest leaf of another tree, attaching a fresh tree's first node under a foreign root. All providers now descend from the root and apply the leaf/root predicate over that subtree; unscoped queries keep the whole-scheme behavior (no perf regression). Fixed in the Pro CTE builders, and — with full Free/Pro parity — in the Free PVT layer: PostgreSQL plpgsql (12_pvt_cte_builder.sql), SQL Server (20_pvt_build_query_sql.sqlfast-path +08_pvt_tree_functions.sqlTVFs, v2-pvt module0.1.4→0.1.6; PG0.6.3→0.6.4), and the SQLite native extension (redb_pvt.c; the prebuilt Windowsredbsqlite.dllis rebuilt — Linux/macOS.so/.dylibmust be recompiled from source per platform). Regression test:TreeTestsBase.TreeQuery_WhereLeaves_ScopedToRoot_DoesNotLeakOtherTreesasserts isolation on all six fixtures. Details indocs/TREE_SCOPED_LEAF_ISOLATION.md.
3.6.0
Why 3.6.0 and not 3.5.2.
redb.Routeadds public API in this release —.PropagateToolHeaders(...)and the matching?propagateToolHeaders=endpoint option — and new surface cannot ship as a patch. The core packages carry only fixes, but the ecosystem moves on one number, and a minor is allowed to contain nothing but fixes for the packages that got none.This also re-unifies the line: 3.5.1 covered
redb.Route,redb.Tsakandredb.Identitywhile the core stayed at 3.5.0. From 3.6.0 all four are on the same number again.The SQLite native extension was rebuilt for every RID. The tree-scope fix below changes
redb_pvt.c, so a package carrying the previous binaries would have shipped the fix on the managed side and left the Free tier leaking across trees — silently, with no error. win-x64, linux-x64 and linux-arm64 are all rebuilt from the current source and verified byte-different from 3.5.0. macOS still ships no extension (it needs a macOS runner).
Fixed
SumRedbAsync/AverageRedbAsyncthrewInvalidOperationExceptionon an empty selection (RedBase.Core, all providers). ASUM/AVGwith no matching rows isNULLin SQL (an aggregate withoutGROUP BYstill returns one row), and the result was read straight throughJsonElement.GetDecimal, which requires aNumberkind and throws onnull. This path became reachable only after 3.5.0 made base-field aggregations honour the filter: before thatWhereRedb(...)was dropped, so the aggregate always spanned the whole (non-empty) scheme and never producedNULL— the filter defect was masking this one. Both methods now treat aNULLresult as0m(an empty sum is 0 —Enumerable.Sumsemantics; a ledger with no movements balances to 0), matching theNULLguardMinRedbAsync/MaxRedbAsyncalready had. New empty-selection tests cover both across all six Free/Pro fixtures.TreeQuery(rootObj).WhereLeaves()/.WhereRoots()ignored the root and scanned the whole scheme (all six providers — Free and Pro). TheIsLeaf/IsRoottree branches built their query from_id_schemealone and droppedcontext.RootObjectId/ParentIds, so a root-scoped leaf/root query returned the leaves/roots of EVERY tree in the scheme. For tree-per-entity storage this leaks across trees — e.g.LoadPathAsync(leaf: null)picked the newest leaf of another tree, attaching a fresh tree's first node under a foreign root. All providers now descend from the root and apply the leaf/root predicate over that subtree; unscoped queries keep the whole-scheme behavior (no perf regression). Fixed in the Pro CTE builders, and — with full Free/Pro parity — in the Free PVT layer: PostgreSQL plpgsql (12_pvt_cte_builder.sql), SQL Server (20_pvt_build_query_sql.sqlfast-path +08_pvt_tree_functions.sqlTVFs, v2-pvt module0.1.4→0.1.6; PG0.6.3→0.6.4), and the SQLite native extension (redb_pvt.c; the prebuilt Windowsredbsqlite.dllis rebuilt — Linux/macOS.so/.dylibmust be recompiled from source per platform). Regression test:TreeTestsBase.TreeQuery_WhereLeaves_ScopedToRoot_DoesNotLeakOtherTreesasserts isolation on all six fixtures. Details indocs/TREE_SCOPED_LEAF_ISOLATION.md.
3.5.0
Why 3.5.0 (a minor bump) when the core packages carry only fixes. The number is shared across the ecosystem, and this release adds public API surface in
redb.Route: the Message History, XSLT and Routing Slip EIPs, plus{{key}}property placeholders in endpoint URIs. Under SemVer that is a minor, and a minor may legitimately contain nothing but fixes for the packages that got none — the reverse (shipping new public API as a patch) would not be legitimate.The whole ecosystem moves together, as it has since 3.3.3: the core packages (
RedBase.Core, the three providers Free/Pro,RedBase.Export,RedBase.CLI,RedBase.Templates,RedBase.Licensing), all ofredb.Route, plusredb.Tsakandredb.Identity— the latter two carry no code changes of their own and are rebuilt onto the new core.Why they are not left behind on 3.4.0. Tsak's shared-runtime layer is gated on the minor version, so a 3.4.x worker refuses to start with a 3.5.0 framework dropped into
Libs/shared— "patch the framework without rebuilding the worker" holds within a minor only. Since Identity runs as modules inside Tsak, leaving both behind would mean the two fixes below that affect every consumer — the stale props-cache entry and the order-dependentDictionaryhash — never reach their users at all.
Fixed
Hashing threw on Blazor WebAssembly, breaking every write (
RedBase.Core,RedBase.Core.Pro). The browser-wasm runtime ships no MD5 provider, soMD5.Create()raisedCryptographicException: Cryptography_UnknownHashAlgorithm, MD5— and since object and scheme hashes are computed on every save,SyncSchemeAsyncandSaveAsyncfailed outright in the browser. All MD5 call sites now go throughRedbMd5, which uses the platform provider where one exists and a managed RFC 1321 implementation where none does (today: browser-wasm only).The algorithm did not change and existing databases are untouched. Hashes are stored as
Guid(16 bytes) and compared by change tracking and cache validation, so the managed implementation is asserted bit-for-bit identical toSystem.Security.Cryptography.MD5— RFC 1321 vectors, every block boundary where padding implementations go wrong (54–57, 63–65, 119–120, 127–129 bytes), and random buffers. On every non-browser target the executed code path is exactly what it was before.Android apps shipped ~360 KB of unusable Linux binaries in every APK (
RedBase.SQLite). The Free tier's native loadable extension was packed into the idiomaticruntimes/<rid>/native/layout — but the .NET Android SDK harvests everyruntimes/*/native/*.soand maps it into the APK by architecture, solinux-arm64/redbsqlite.solanded inlib/arm64-v8a/andlinux-x64/redbsqlite.soinlib/x86_64/. Besides the dead weight, this raisedXA0141: Android 16 requires 16 KB page alignment, so the app would fail to start there. Affected every Android consumer, including those onRedBase.SQLite.Pro(which depends on the Free package).The extension now ships under
buildTransitive/native/<rid>/, where no platform SDK looks for it, andredb.SQLite.targetsperforms the delivery. The targets file previously handled only the no-RuntimeIdentifier case — RID-specific publish relied on NuGet flatteningruntimes/— so it was extended to cover both. The output layout is unchanged (runtimes/<rid>/native/), so extension resolution behaves exactly as before on desktop and server.Props cache could serve a stale ("dirty") object mutated in place after it was cached (
RedBase.Core+ all providers).MemoryRedbObjectCachestores a reference to the object, not a copy. When a caller mutated a loaded object in place before saving it, the cache — pointing at the same object — instantly reflected the mutation, but its stored hash (fixed atSet) did not. A subsequentLoadAsyncsaw hash-matches-DB and returned the mutated object as if it were the committed state. The cache now recomputes the object's live hash on every read (Get/GetWithoutHashValidation/GetInternal) and compares it to the hash captured atSet; if they differ, the cached snapshot is dirty → treated as a MISS so the caller reloads the committed state from the DB. Surfaces only withEnablePropsCache=true(off by default). No clone/allocation on read — just the hash recompute.RedbHashwas order-dependent forDictionaryproperties (RedBase.Core+ all providers). ADictionary<K,V>was hashed in enumeration order, but .NET does not guarantee that order (it also changes after removals), and the same logical map is rebuilt in a different order when materialized from_valuesthan when first created. So one logical object produced different hashes on the create path vs the load path, desynchronizing_objects._hashfrom the cache and corrupting any hash-based cache validation for Dictionary-bearing Props. Dictionaries are now hashed by content, order-independent (pairs canonicalized as sortedkey=valueHash). Arrays and lists are unchanged — their order is significant.Base-field aggregations silently ignored the query filter on Pro (
RedBase.*.Pro).SumRedbAsync/AverageRedbAsync/MinRedbAsync/MaxRedbAsync/AggregateRedbAsync(and the Props-basedGetStatisticsAsync) routed theirWhere/WhereRedbfilter through the legacyfilterJsonprovider overload. The Pro providers deliberately dropfilterJson— real filtering happens through the compiledFilterExpressionoverload — so those aggregations ran over the whole scheme, returning a sum/average/min/max/count as if no filter were applied. (Free was correct: it convertsfilterJsonto facet-JSON.) These call sites now pass theFilterExpressiondirectly, the same pathAggregateAsyncalready used. Enriched aggregation tests now exercise every*RedbAsyncmethod with a filter across all six Free/Pro × Postgres/MSSql/SQLite fixtures — the previous suite had zero filtered-*RedbAsynccoverage, which is why the defect shipped.Integer aggregate results collapsed to 0 on MSSql (
RedBase.Core, all providers via MSSql). MSSql rendersSUM/MIN/MAXof a basebigintfield asnumeric(38,10), soFOR JSONemits it with a zero fraction (2130762.0000000000).JsonValueConverterread integer targets withTryGetInt64, which rejects any JSON number carrying a decimal point, and silently fell back to0— so e.g.AggregateRedbAsync(o => new { Sum = Agg.Sum(o.Id) })returned 0 on MSSql while Postgres and SQLite (which render plain integers) were correct. The converter now reads integer targets through anumeric-tolerant path that truncates a zero fraction, mirroring the provider-rendering tolerance it already applies tobool(SQLite 0/1) andDateTime. A value beyond the target type's range now overflows loudly instead of collapsing to 0.
3.5.0
Why 3.5.0 (a minor bump) when the core packages carry only fixes. The number is shared across the ecosystem, and this release adds public API surface in
redb.Route: the Message History, XSLT and Routing Slip EIPs, plus{{key}}property placeholders in endpoint URIs. Under SemVer that is a minor, and a minor may legitimately contain nothing but fixes for the packages that got none — the reverse (shipping new public API as a patch) would not be legitimate.The whole ecosystem moves together, as it has since 3.3.3: the core packages (
RedBase.Core, the three providers Free/Pro,RedBase.Export,RedBase.CLI,RedBase.Templates,RedBase.Licensing), all ofredb.Route, plusredb.Tsakandredb.Identity— the latter two carry no code changes of their own and are rebuilt onto the new core.Why they are not left behind on 3.4.0. Tsak's shared-runtime layer is gated on the minor version, so a 3.4.x worker refuses to start with a 3.5.0 framework dropped into
Libs/shared— "patch the framework without rebuilding the worker" holds within a minor only. Since Identity runs as modules inside Tsak, leaving both behind would mean the two fixes below that affect every consumer — the stale props-cache entry and the order-dependentDictionaryhash — never reach their users at all.
Fixed
Hashing threw on Blazor WebAssembly, breaking every write (
RedBase.Core,RedBase.Core.Pro). The browser-wasm runtime ships no MD5 provider, soMD5.Create()raisedCryptographicException: Cryptography_UnknownHashAlgorithm, MD5— and since object and scheme hashes are computed on every save,SyncSchemeAsyncandSaveAsyncfailed outright in the browser. All MD5 call sites now go throughRedbMd5, which uses the platform provider where one exists and a managed RFC 1321 implementation where none does (today: browser-wasm only).The algorithm did not change and existing databases are untouched. Hashes are stored as
Guid(16 bytes) and compared by change tracking and cache validation, so the managed implementation is asserted bit-for-bit identical toSystem.Security.Cryptography.MD5— RFC 1321 vectors, every block boundary where padding implementations go wrong (54–57, 63–65, 119–120, 127–129 bytes), and random buffers. On every non-browser target the executed code path is exactly what it was before.Android apps shipped ~360 KB of unusable Linux binaries in every APK (
RedBase.SQLite). The Free tier's native loadable extension was packed into the idiomaticruntimes/<rid>/native/layout — but the .NET Android SDK harvests everyruntimes/*/native/*.soand maps it into the APK by architecture, solinux-arm64/redbsqlite.solanded inlib/arm64-v8a/andlinux-x64/redbsqlite.soinlib/x86_64/. Besides the dead weight, this raisedXA0141: Android 16 requires 16 KB page alignment, so the app would fail to start there. Affected every Android consumer, including those onRedBase.SQLite.Pro(which depends on the Free package).The extension now ships under
buildTransitive/native/<rid>/, where no platform SDK looks for it, andredb.SQLite.targetsperforms the delivery. The targets file previously handled only the no-RuntimeIdentifier case — RID-specific publish relied on NuGet flatteningruntimes/— so it was extended to cover both. The output layout is unchanged (runtimes/<rid>/native/), so extension resolution behaves exactly as before on desktop and server.Props cache could serve a stale ("dirty") object mutated in place after it was cached (
RedBase.Core+ all providers).MemoryRedbObjectCachestores a reference to the object, not a copy. When a caller mutated a loaded object in place before saving it, the cache — pointing at the same object — instantly reflected the mutation, but its stored hash (fixed atSet) did not. A subsequentLoadAsyncsaw hash-matches-DB and returned the mutated object as if it were the committed state. The cache now recomputes the object's live hash on every read (Get/GetWithoutHashValidation/GetInternal) and compares it to the hash captured atSet; if they differ, the cached snapshot is dirty → treated as a MISS so the caller reloads the committed state from the DB. Surfaces only withEnablePropsCache=true(off by default). No clone/allocation on read — just the hash recompute.RedbHashwas order-dependent forDictionaryproperties (RedBase.Core+ all providers). ADictionary<K,V>was hashed in enumeration order, but .NET does not guarantee that order (it also changes after removals), and the same logical map is rebuilt in a different order when materialized from_valuesthan when first created. So one logical object produced different hashes on the create path vs the load path, desynchronizing_objects._hashfrom the cache and corrupting any hash-based cache validation for Dictionary-bearing Props. Dictionaries are now hashed by content, order-independent (pairs canonicalized as sortedkey=valueHash). Arrays and lists are unchanged — their order is significant.Base-field aggregations silently ignored the query filter on Pro (
RedBase.*.Pro).SumRedbAsync/AverageRedbAsync/MinRedbAsync/MaxRedbAsync/AggregateRedbAsync(and the Props-basedGetStatisticsAsync) routed theirWhere/WhereRedbfilter through the legacyfilterJsonprovider overload. The Pro providers deliberately dropfilterJson— real filtering happens through the compiledFilterExpressionoverload — so those aggregations ran over the whole scheme, returning a sum/average/min/max/count as if no filter were applied. (Free was correct: it convertsfilterJsonto facet-JSON.) These call sites now pass theFilterExpressiondirectly, the same pathAggregateAsyncalready used. Enriched aggregation tests now exercise every*RedbAsyncmethod with a filter across all six Free/Pro × Postgres/MSSql/SQLite fixtures — the previous suite had zero filtered-*RedbAsynccoverage, which is why the defect shipped.Integer aggregate results collapsed to 0 on MSSql (
RedBase.Core, all providers via MSSql). MSSql rendersSUM/MIN/MAXof a basebigintfield asnumeric(38,10), soFOR JSONemits it with a zero fraction (2130762.0000000000).JsonValueConverterread integer targets withTryGetInt64, which rejects any JSON number carrying a decimal point, and silently fell back to0— so e.g.AggregateRedbAsync(o => new { Sum = Agg.Sum(o.Id) })returned 0 on MSSql while Postgres and SQLite (which render plain integers) were correct. The converter now reads integer targets through anumeric-tolerant path that truncates a zero fraction, mirroring the provider-rendering tolerance it already applies tobool(SQLite 0/1) andDateTime. A value beyond the target type's range now overflows loudly instead of collapsing to 0.
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.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.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.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.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).
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.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.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.
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.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.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.
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.