Skip to content

Releases: eQuantic/core-data

v6.9.0

Choose a tag to compare

@github-actions github-actions released this 27 Jul 13:35

6.9.0 (2026-07-27)

Features

  • evolution: the drift comparison moves to the contracts (f3be7c3)

v6.8.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 16:15

The three schema changes the generator used to hand back

eqdata could describe a resize, a collection rename and a collection drop; the engine had no operation for any of them, so the generated file emitted a #error and left them to you. Two now run, and the third is one edit away.

migration.For<OrderData>(order => order
    .ResizeField(x => x.Reference)                        // varchar(50) -> varchar(200)
    .RenameCollection("orders", "sale_orders"));

ResizeField takes only the field: the size is the model's, never the caller's, so the operation cannot disagree with the mapping. It is distinct from ConvertField, which changes what kind of thing is stored and rewrites values — this changes only how much room it has, so the store does the work.

Per store, what each can honestly do:

relational all three — SQL Server renames through sp_rename, MySQL restates with MODIFY
Cassandra drops a table; refuses to rename one (no ALTER does it) and has no sized types to resize
MongoDB drops and renames a collection; resizing is a no-op
Cosmos DB deletes a container; refuses to rename one (the name is fixed at creation); resizing is a no-op

Every refusal names what to do instead. The no-ops are deliberate: one migration is written for six stores, so a step that means nothing on one of them must not throw there.

Dropping a collection still stops the build. It deletes everything in the table, and a model diff cannot tell whether that data is finished with — but the #error names the operation, so it is one edit away.

The model no longer has to live in the application

eqdata migrations add AddCustomerTier \
  --project         src/Shop.Data \
  --startup-project src/Shop.Api

The tool runs an application, and only an application can be run: a library produces no runtimeconfig.json, so the assemblies a model depends on cannot be located from one. --project is where the migrations belong and whose namespace they take; --startup-project is what gets run.

Both assemblies are searched for the design-time services and for the snapshot — which matters, because the snapshot is committed beside the model, in exactly the project that is not being run. Pointing at a library alone still fails, but now it names the option to reach for.

MongoDB drift stops being unanswerable

A collection has no schema, and none is claimed: no field is compared on MongoDB or Cosmos DB, because a document either carries a property or it does not, and sampling would describe the documents that came back rather than the collection.

But a collection does carry the indexes the model asked for — and one of them is not about speed:

The mongodb database and the model disagree:

  expiring_docs  (Shop.ExpiringDoc)
    ttl_CreatedAt is not there — the model expects CreatedAt:1

A time-to-live declaration is delivered as an index. Without it nothing expires, and documents that should have been deleted are still being read. That is the one index whose absence changes what the store holds, so it is the one that fails the check.

Every other index changes how fast a query answers, not whether it does — so a missing or differing one is reported without failing the gate, and an index nobody declared is reported and ignored, like a column nobody mapped.

Store What drift compares
PostgreSQL · MySQL · MariaDB · SQL Server every mapped table and column, each type and nullability
Cassandra the same through system_schema, plus the partition key
Cosmos DB the containers and the partition key paths they were created with
MongoDB the collections and their indexes

A shared Cosmos container is checked once

Sharing a container between entity types is the Cosmos idiom, not a mistake. Five types in one container used to describe it five times and report every difference five times, which reads as five problems. It is now one container named after all the types that map to it, so a finding still says which are affected.


Verified against real stores: MongoDB 148, Cassandra 107, PostgreSQL 89, Cosmos DB 56, MySQL 19, core 83, analyzers 13. Every schema change is checked by asking the database afterwards, not by inspecting the SQL. The startup-project split is verified end to end against a library plus application pair. SQL Server runs in CI (its image is amd64 only).

Docs: Generating and drift checking

v6.7.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 15:02

The tooling reaches all six stores

eqdata migrations add worked on the relational stores only, for one reason: they were the only ones whose model could describe itself. Cassandra, MongoDB and Cosmos DB now describe themselves too — each in the way it honestly can — and the two things a document store needs beyond that are here as well.

Every store can now be compared against its own history

Store Described from
Cassandra its configuration: table, columns, CQL types, and the partition and clustering keys that together identify a row
MongoDB the driver's own class maps — element renames and exclusions included
Cosmos DB the containers and partition key paths from the model, the properties from the type, named as the serializer names them

MongoDB reads class maps rather than reflecting over the type on purpose. A collection has no schema, so the only truthful answer to "what shape is this" is the mapping the driver will actually use when it writes. Reflecting over the type would describe a shape nobody writes.

AddField stops being a no-op on document stores

This was the gap worth closing. A collection needs no declaration to accept a new field — documents gain one on write. The documents already there are the problem. Absent the field, deserialization hands your application default(T): a 0, an Unspecified date, the first value of an enum. None of those is distinguishable from a value somebody meant.

public sealed class Ledger
{
    [DefaultValue("web")] public string Channel { get; set; } = "";
}

migration.For<Ledger>(ledger => ledger.AddField(x => x.Channel));
// every document without `channel` now holds "web" — the ones that had a value keep it

Declare nothing and it stays a no-op, deliberately: an absent field is at least visible, and a value nobody chose is not. Cosmos has no set-based update, so this costs one read and one patch per document that lacks the field — which is why the query filters on absence rather than rewriting everything.

Cassandra needs none of it. It has a real schema, so AddField is an ALTER TABLE ... ADD and a missing value reads as null.

[DefaultValue] and [PreviousName] now work everywhere

Both are read from the member itself, through one shared reader, rather than only from a relational column. A document store has nowhere else to put them — the class is the schema. The fluent .Default and .PreviousName stay relational, where the model has a column to hang them on, and still take precedence there.

Drift checking reaches Cassandra and Cosmos DB

Store What is compared
PostgreSQL · MySQL · MariaDB · SQL Server every mapped table and column, each type and nullability
Cassandra the same, through system_schema, plus the partition key
Cosmos DB the containers and the partition key paths they were created with
MongoDB nothing — and it says so

The partition key is the finding worth having. It is fixed when a table or container is created, so a different one cannot be migrated at all — only rebuilt alongside and copied into. Both stores now report it, and the report says that outright instead of implying a fix exists.

MongoDB stays unanswerable and explains why: a collection has no shape beyond the documents in it, and sampling those would describe the documents that came back rather than the collection.

One bug fixed on the way

Cassandra folds every unquoted identifier to lower case, and the provider never quotes one — so a model saying OpenedAt describes a column called openedat. Comparing the two spellings reported every column of every correct table. A drift check that cries wolf on a healthy keyspace is worse than none, so this was the difference between shipping the feature and not.


Verified against real stores: MongoDB 139, Cassandra 103, Cosmos DB 51, PostgreSQL 83, MySQL 19, core 83, analyzers 13. SQL Server's 14 skip locally — its image is amd64 only — and run in CI.

Docs: Generating and drift checking

v6.6.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 14:07

Migrations you no longer write by hand — and a database that answers for itself

Three pieces land together, because each is only useful with the others.

eQuantic.Core.Data.Abstractions — new package

The contract surface now ships on its own: the repository, unit-of-work and set interfaces, the query and update models, the modeling attributes, the migration operations and the model snapshot.

A domain or application layer references it and compiles against no engine and no store — only the composition root takes the provider packages.

dotnet add package eQuantic.Core.Data.Abstractions

Nothing moved namespace, and nothing moved type identity: 105 type forwards keep anything already compiled against 6.5.0 working. You get the contracts either way — eQuantic.Core.Data depends on them.

eQuantic.Core.Data.Tools — new package

dotnet tool install --global eQuantic.Core.Data.Tools
eqdata migrations add AddCustomerTier --project src/Shop.Api

Compares the model against a snapshot committed beside it and writes the migration that carries one to the other, plus the regenerated snapshot. Both files, or neither — a snapshot that advanced past a change nobody generated is worse than no snapshot, because the next comparison starts from a state the database was never brought to.

The tool reads the model from your application itself, through one class:

public sealed class DesignTimeServices : IDesignTimeServices
{
    public IServiceProvider Create(string[] args) { /* the configuration the app uses */ }
}

Where it stops and asks. Generation is a starting point, not an authority. Where the tooling knows what moved but only a person knows what it means, the generated file emits #error and the solution does not build until it is answered. A comment would let the change run, appear to succeed, and quietly leave the data wrong.

migration.For<global::Shop.OrderData>(entity => entity
    .AddField(x => x.Tier)
    .Update(_ => true, set => set.Set(x => x.Tier, default!)));
#error 'Shop.OrderData.Tier' is added without saying what the records that already exist hold. …

Three things trigger it: a member added with no declared value (every existing record would take default(T)), a rename nobody declared (generating drop-and-add loses the values), and a change no store operation performs.

Renames keep the data — if you say so. A rename and a drop-and-add look identical in a diff, and only one keeps the values. Say where a member came from and the pair becomes RenameField("customer", "buyer"):

[PreviousName("customer")] public string Buyer { get; set; } = "";
[DefaultValue("web")]      public string Channel { get; set; } = "";

Both also have fluent forms: .PreviousName(x => x.Buyer, "customer") and .Default(x => x.Channel, "web").

Refusals are not rendered at all. Cassandra cannot relocate rows under a moved partition or clustering key — there is no ALTER that does it — and no store will redefine a key silently. Nothing is written when a refusal appears, because generating the rest would advance the snapshot past a change that never ran. Each refusal names what to do instead.

eqdata drift

eqdata drift --project src/Shop.Api || exit 1
The postgresql database and the model disagree:

  orders  (Shop.OrderData)
    reference is varchar(50), and the model expects varchar(200)

This is the question a migration history cannot answer. History records which changes ran. It says nothing about a column altered by hand on staging, a migration that stopped halfway, or an environment restored from a backup older than the last release. Only looking answers those.

Exits non-zero when the difference is one the application would fail on, so it works as a deployment gate. A column the model does not map is reported but does not fail the check — databases get shared. Tables you do not map are not read at all. It also reports, separately, when the model has moved beyond the committed snapshot: that is not drift, the database is behind the code on purpose until a migration runs.

Why a clean database is silent. Each dialect reads its own catalogue and returns the type in the spelling it writes itself — PostgreSQL through format_type(), MySQL through column_type (which keeps tinyint(1) and datetime(6)), SQL Server composed in sys.columns (where max_length counts bytes, so an nvarchar(450) reports 900). A check that cries wolf on a healthy schema is a green light nobody reads.

Only the relational providers read their own catalogue. The document stores have no schema to introspect, and drift says it cannot answer rather than answering wrongly.

Two defects fixed on the way

RenameField resolved its source name from the current model. In a generated migration the model has already moved, so the rename resolved to the new name and left the old column untouched. There is now an overload stating both sides — RenameField("customer", "buyer") — honoured by all four executors.

The expected side of a drift check derives nullability from what CREATE TABLE actually writes, not from the CLR type. The engine emits no NOT NULL for ordinary columns, so expecting otherwise reported a finding for every correct table. A column that does carry one is now reported as tightened by hand — which is the constraint that starts rejecting writes your code allows.


Docs: Generating and drift checking

v6.5.0

Choose a tag to compare

@github-actions github-actions released this 24 Jul 21:34

v6.5.0 — the data model comes home

eQuantic.Core.DataModel now lives in this repository, beside the engine it describes, and versions with it.

What changes for you

Nothing in the API — the types are the same. What changes is where the package comes from and how it is numbered:

  • It ships from this repo, packed and published alongside the providers, so eQuantic.Core.DataModel is now at 6.5.0 and moves with each core-data release.
  • eQuantic.Core.Data depends on that same version, so the engine and the model it maps can no longer drift apart: what the repository builds against is exactly what ships.
  • 4.0.0 stays on NuGet and keeps working. The source is unchanged, so a project that pins it — eQuantic.Core.Persistence from core-ddd, for instance — resolves cleanly either way.

The project also declares its own target frameworks (net8.0, net10.0), which it used to inherit from the repository it came from.

Why

IEntity<TKey> is the contract the engine's repositories are written against — IAsyncRepository<TEntity, TKey> constrains on it. Keeping that contract in a separate repository meant every change to it crossed a release boundary before the engine could use it. Now the two move together, and a change to the model is a change to the engine's own build.


Full changelog: v6.4.0...v6.5.0

v6.4.0

Choose a tag to compare

@github-actions github-actions released this 24 Jul 09:24

v6.4.0 — the native-binary release

Three features land together, and they share a theme: the stack now runs as a NativeAOT binary, and the two escape hatches you reach for at scale — bulk load and raw SQL — are here, honest about what they cost.

🚀 The stack runs as a native binary

The PostgreSQL stack provably publishes and runs under NativeAOT: samples/AotProbe is a PublishAot app that opens a real PostgreSQL connection, migrates, and round-trips an entity — a ~12 MB self-contained binary with no JIT. Getting there meant finding and closing every wall the linker hit, at the library level, so your app code stays plain:

  • Closed-generic registrationAddPostgreSqlRepository<TEntity, TKey>() / AddRelationalRepository<…> register over factories instead of open generics over a value-type key (which the AOT DI graph cannot honour).
  • Explicit unit-of-work activationAddPostgreSqlUnitOfWork(factory) replaces typeof-based activation with a real new.
  • Operator rooting — a module initializer's [DynamicDependency] roots the decimal / DateTime / DateTimeOffset / TimeSpan / DateOnly / TimeOnly operators, so Expression.MakeBinary(LessThan, …) no longer trims decimal.op_LessThan out from under a query.

🗂️ Explicit migration registration — the last AOT wall

Assembly-scanning migration discovery trimmed the migration constructors. It now has a reflection-free sibling on all four providers:

services.AddPostgreSqlMigrations(source => source
    .Add<ProductsSetup>()
    .Add<ProductsBackfill>());

Both the scan form and the explicit form funnel through one shared MigrationDiscovery.Pending — merge, dedupe, throw on two migrations claiming the same id, order by timestamp. AotProbe is now plain app code: no [DynamicDependency], no descriptor, no ILLink XML anywhere.

Still not clean-AOT (and honestly documented as such): Expression.Compile falls back to the interpreter, jsonb awaits a JSON source-gen context, and the wire-format ExpressionSerializer is [RequiresDynamicCode] upstream. The DAM-propagation warnings that remain are warnings, not failures. See Trimming and NativeAOT.

📦 Native bulk load + typed raw SQL

Two escape hatches for the moments the ordinary write model and the pushdown engine are the wrong tool.

BulkInsertAsync streams entities through the store's native bulk mechanism:

Store Mechanism
PostgreSQL binary COPY … FROM STDIN
SQL Server SqlBulkCopy
MySQL / MariaDB MySqlBulkCopy

Loading 1 000 rows costs 6.5 ms against 13.2 ms for a hand-written 1 000-statement DbBatch (0.49×) and 40.3 ms for EF Core (6.2× faster), at 0.28× the baseline's allocations.

A dialect with no native path refuses rather than quietly running a row-by-row batch — a "bulk" API that is secretly per-row is exactly the hidden cost this engine does not ship. MySQL surfaces the LOAD DATA LOCAL INFILE requirement (both AllowLoadLocalInfile=true and the server's local_infile=1) instead of flipping a security-relevant switch behind your back.

QueryAsync<TResult> runs arbitrary SQL and materializes by column name — case-insensitive, snake_case-tolerant, plan cached per result shape — with ExecuteAsync for non-queries:

var totals = await uow.QueryAsync<CategoryTotal>(
    "SELECT category, COUNT(*) AS orders, SUM(total) AS total FROM sale_orders GROUP BY category");

The SQL is yours, so the guarantees the engine normally makes — query filters, soft-delete, pushdown analysis, Explain() — deliberately do not apply. Parameters bind positionally as @p0, @p1…; never interpolate. That trade is documented where the API is.


Packages (all at 6.4.0): Core · Relational · PostgreSql · MySql · SqlServer · MongoDb · CosmosDb · Cassandra. Requires DataModel 4.0.0 + Domain 4.0.0.

Covered by integration tests on all three relational stores and a bulk-vs-batch benchmark. Full changelog: v6.3.0...v6.4.0

v6.3.0

Choose a tag to compare

@github-actions github-actions released this 23 Jul 09:17

v6.3.0 — first-class logging and metrics

This release makes the engine observable through the tools you already use. Point Serilog (or
NLog, or the console) at it and every executed query shows up in your logs — the same way EF Core
does it, with no logger-specific packages to install.

Logging — Microsoft.Extensions.Logging, EF-style

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Override("eQuantic.Core.Data", LogEventLevel.Information)
    .WriteTo.Console()
    .CreateLogger();

Add the assembly's category to your configuration and executed queries log with placeholders,
elapsed time and row counts. Categories are stable (eQuantic.Core.Data.{provider}.Command,
and eQuantic.Core.Data.cosmosdb.Request) with stable event ids, so any
Microsoft.Extensions.Logging sink — Serilog, NLog, the ASP.NET console — plugs in through the
providers it already ships. No eQuantic.Core.Data.Serilog package needed: the mechanism serves
every logger on day one.

Event Id Level Carries
CommandExecuted 10001 Information statement (placeholders), elapsed, rows; Cosmos adds status + RU charge
CommandFailed 10002 Error statement + exception
CommitExecuted 10101 Information staged writes flushed, elapsed
ClientEvaluation 10201 Warning a residual that ran client-side (behind its opt-in)
AllowFiltering 10202 Warning a Cassandra query running as a declared scan
QuerySplit 10203 Warning an OR filter fanned out into parallel native queries
ConcurrencyConflict 10301 Warning expected vs affected on a lost race

Beyond what EF logs

The pushdown gates log at Warning — an opt-in that quietly became a hot path's habit surfaces
in production logs, not in an incident review. And Cosmos DB logs the request charge (RU) per
operation, the number Cosmos operators actually chase.

Values are opt-in, always

Parameter values never log by default — statements carry placeholders, the same policy the traces
follow. Turn them on the way you turn EF's on, deliberately, per environment:

services.AddSingleton(new DataConventions { EnableSensitiveDataLogging = true });

Metrics — one Meter for the dashboards

services.AddOpenTelemetry().WithMetrics(m => m.AddMeter("eQuantic.Core.Data").AddOtlpExporter());

Command counters and a duration histogram, commit/write counters, and — the differentiator — the
gate counters (equantic.client_evaluations, equantic.allow_filtering,
equantic.query_splits, equantic.concurrency_conflicts) that make the engine's honesty
graphable: a rising client-evaluation line is an alert, not an archaeology project.

How it's wired

One seam per provider, each the store's natural one — a delegating DbCommand on the relational
engine (catches reads, aggregates, set-based writes and includes in one place), the
prepared-statement executor on Cassandra, the driver's own command events on MongoDB, a
RequestHandler in the pipeline on Cosmos DB. An ILoggerFactory from DI is optional; without one
the engine logs to a null logger and costs nothing.

Verification

Suites green on real stores: PostgreSQL 55 (the logging contract itself pinned — stable category,
placeholder default, sensitive opt-in, commit event), Cassandra 94, MongoDB 131, Cosmos DB 41,
core 62.

Compatibility: no breaking changes; adds a floored Microsoft.Extensions.Logging.Abstractions
dependency (8.0.0 on net8, 10.0.0 on net10 — your own generation, never forced upward). Requires
eQuantic.Core.DataModel 4.0.0 / eQuantic.Core.Domain 4.0.0 (as since v6.0.0). Full guide:
Operations → Observability.

v6.2.0

Choose a tag to compare

@github-actions github-actions released this 23 Jul 08:48

v6.2.0 — measured, diagnosed, generated

This release is the performance-and-tooling round: published benchmarks that drove two engine
optimizations to the raw-driver floor, compile-time model diagnostics, and source-generated
entity accessors
— both shipped inside the core package, no extra installs.

Published benchmarks — and the loop they exist for

The repository now carries a comparative suite (benchmarks/) against EF Core, Dapper and raw
Npgsql
over a real PostgreSQL 17: point read, filtered set, projection, paging, single insert,
100-row commit, set-based update. Results, methodology and the honest reading live in the docs:
Operations → Benchmarks.

The first run named two losses — and both were fixed at the engine level and re-measured:

Scenario First run Now The fix
Projection (500 rows) 1.29× of raw 0.92× reader-direct projectors (no entity shells, no per-query JIT) + a structural projector cache
Single insert + commit 1.84× 1.00× one-statement flushes skip the local transaction (a single statement is atomic on its own)

Where the table stands (Apple M4 Pro, .NET 10, ShortRun): every read scenario at or below the raw
Npgsql baseline within noise, ahead of EF Core across the board; the batch flush at hand-written
DbBatch speed — ~2× faster than EF Core, ~10× faster than per-row Dapper inserts.

Compile-time model diagnostics (EQD001–EQD011)

eQuantic.Core.Data now ships a Roslyn analyzer (bundled — every consumer gets it): the modeling
misuses that are wrong on every provider surface as warnings while typing, with the fix in the
message — a concurrency token no store can version, ambiguous [PartitionKey]/[ClusteringKey]
orders, multiple [EntityKey] members, invalid [Facet]s, contradictory [Unmapped], and more.
Provider-relative rules deliberately stay at runtime, where the provider is known and the message
can be exact. The full table.

Source-generated entity accessors

A source generator (also bundled) emits reflection-free accessors — construction, member reads and
writes as direct code — for every entity in the consuming compilation, registered automatically.
Relational materialization and column reads use them when present; reflection remains the
fallback contract
, so nothing changes for assemblies compiled without the generator, and types
the generated code could not honor faithfully (init-only setters, no accessible parameterless
constructor) stay on the reflection path by design.

Measured effect on the benchmarks: within noise — stated plainly in the docs, because that is the
honest finding. The accessors exist to remove the reflection dependence itself: the groundwork for
trimming/NativeAOT support.

Also in this release

  • Mapped reads (GetMappedAsync, paged maps) narrow their SELECT and build results in one pass.
  • The benchmark harness documents its methodology (per-stack idiomatic setup, state isolation) and
    reproduces with one command — Docker included, container owned by the run.

Verification

Suites green across the round on real stores: PostgreSQL 53 (through the generated accessors),
SQL Server 13, MySQL 16, MongoDB 131, Cosmos DB 41, Cassandra 94, core 62, analyzers 13.

Compatibility: no breaking changes. Requires eQuantic.Core.DataModel 4.0.0 /
eQuantic.Core.Domain 4.0.0 (as since v6.0.0).

v6.1.0

Choose a tag to compare

@github-actions github-actions released this 22 Jul 19:15

v6.1.0 — the modeling matrix, complete — and the documentation site

The store-neutral modeling vocabulary now lands on every store that has the concept, as that
store's native mechanism
— and the project has a full documentation site:
https://equantic.github.io/core-data/

The annotation × provider matrix is closed

Annotation Relational (PG/MySQL/MariaDB/SQL Server) Cosmos DB Cassandra MongoDB
[Entity] / [EntityKey] / [StoredAs] / [Unmapped] new new new
[PartitionKey] hierarchical multi-hash (≤ 3 levels), new
[ClusteringKey] ✅ ordered index new ✅ composite index new ✅ native ✅ compound index new
[ConcurrencyToken] ✅ ETag LWT, new conditional replace, new
[SearchIndex] GIN trigram (PG), new ✅ SASI
[TimeToLive] default_time_to_live, new TTL index, new
[Facet] new varchar(n) / numeric(p,s)

Highlights

Cosmos DB: one serializer contract, end to end

CosmosEntitySerializer (System.Text.Json) applies [StoredAs]/[Unmapped] through the
serialization contract and extends the SDK's CosmosLinqSerializer, so LINQ member translation
asks the same contract: filters, sorts and projections on a renamed member hit the stored element —
a rename can never desynchronize documents from queries. Queries on [Unmapped] members refuse
loudly instead of matching nothing. Type-level Converts<TMember, TStored> keeps filter constants
converting identically to documents. Hierarchical partition keys (up to three levels) create
multi-hash containers, point writes build the multi-value key transparently, and partition
inference declines rather than guessing.

MongoDB: the fluent model it was missing

MongoModelBuilderCollection / Key / Field / Ignore / per-member Converts /
ConcurrencyToken / ClusteringKey / TimeToLive — over the driver's class maps, so the LINQ
provider renders filters against renames and converted values. Plus MongoModel.Explain() reading
the actual driver state.

Optimistic concurrency: one exception, four native mechanisms

[ConcurrencyToken] now means the same thing everywhere — a stale write must fail, never
silently win
— as a versioned WHERE (relational), an _etag If-Match (Cosmos), a
lightweight transaction (Cassandra: INSERT … IF NOT EXISTS / UPDATE … IF version = old,
refusing the LOGGED BATCH instead of degrading), and a version-filtered conditional replace
(MongoDB). All four throw ConcurrencyConflictException on the lost race.

Native TTL and search

[TimeToLive] lands as Cassandra's default_time_to_live and MongoDB's per-document TTL index
(from the lifecycle CreatedAt, or an explicit member via fluent — the semantic difference is
documented, not hidden). [SearchIndex] on PostgreSQL materializes a GIN trigram index
(pg_trgm) in EnsureCollection() — substring LIKE stops scanning; dialects without an
equivalent ignore the declaration (same semantics, unindexed plan) and Explain() says which.

Relational: composite keys, facets, ordered reads

Key(x => new { x.OrderId, x.LineNo }) — composite PRIMARY KEY DDL, point lookups by tuple
(GetAsync((orderId, lineNo))), updates/deletes addressing all key columns; keyset paging and
reference includes refuse composite targets with guidance. [Facet] sizes the DDL
(varchar(n)/nvarchar(n), numeric(p,s)). [ClusteringKey] materializes a direction-aware
multi-column index.

Explain() everywhere

All four models now report every mapping decision — names, stored types, keys, tokens, TTLs,
indexes, lifecycle — the way Explain() reports a query. Pin the lines you rely on in a test.

Documentation site

https://equantic.github.io/core-data/ — getting started per provider, the concepts and the
pushdown gates explained didactically, the full modeling matrix, per-store deep dives with the
why of every rejection, cookbook (multi-tenancy, value objects, auditing, a coming-from-EF
translation table), architecture/SPI guides, and the complete API reference generated from the
source docs. Every API shape shown was verified against the source.

Verification

All integration suites green against real stores: PostgreSQL 51, Cosmos DB 41 (vNext emulator,
hierarchical keys proven end to end), MongoDB 131, Cassandra 94, SQL Server 13, MySQL 16, core 62 —
including lost-race writes throwing ConcurrencyConflictException, TTLs read back from
system_schema, trigram indexes in pg_indexes, and document-shape ground truth via raw JSON/BSON.

Compatibility: no breaking changes. Requires eQuantic.Core.DataModel 4.0.0 /
eQuantic.Core.Domain 4.0.0 (as since v6.0.0).

v6.0.0

Choose a tag to compare

@github-actions github-actions released this 22 Jul 16:46

6.0.0 (2026-07-22)

A focused, breaking release that inverts one dependency: IEntity/IEntity<TKey> now live in eQuantic.Core.DataModel, and eQuantic.Core.Data references it instead of the other way around. The engine can now build on the unified DataModel entity interfaces, and the package graph is finally acyclic and correctly ordered: eQuantic.Core.Data → eQuantic.Core.DataModel → eQuantic.Core.Domain.

What changed

  • IEntity / IEntity<TKey> moved to eQuantic.Core.DataModel (4.0.0), keeping the historical namespace eQuantic.Core.Data.Repository — so source stays compatible. eQuantic.Core.Data forwards the types (TypeForwardedTo), so assemblies compiled against ≤ 5.8 stay binary-compatible — they still resolve eQuantic.Core.Data.Repository.IEntity, now from DataModel.
  • IEntity<TKey> now extends IDomainEntity<TKey>: one entity satisfies both the domain and data-layer contracts (same GetKey/SetKey).

BREAKING CHANGE — upgrade together

eQuantic.Core.Data 6.0.0 requires eQuantic.Core.DataModel 4.0.0 and eQuantic.Core.Domain 4.0.0. Restoring 6.0.0 pulls them automatically. The only thing that breaks is mixing the new packages with an older eQuantic.Core.Data (≤ 5.8) in the same project — two assemblies would define IEntity, giving a duplicate/ambiguous type (CS0433/CS0311). Bump every eQuantic.Core.Data* and eQuantic.Core.DataModel/eQuantic.Core.Domain reference in lockstep and the type unifies through the forward. No code changes are required — the namespaces and members are unchanged.

Validated end to end against real servers (PostgreSQL, MySQL, MariaDB, SQL Server, Cassandra, MongoDB, Cosmos DB vNext), including a DataModel-based EntityHistoryDataBase entity exercising the full who/when audit through the inverted dependency.