Add a storage agnostic [FirstOrDefault] attribute - #3933
Merged
Conversation
[Entity] needs an identity to load by, so it cannot express the singleton document -- a type a system stores exactly one of, looked up by nothing at all. Today that forces handler code to name a session type and hand-write the query, which pins the code to one store. [FirstOrDefault] resolves the equivalent of session.Query<T>().FirstOrDefaultAsync() through whichever persistence provider owns the type, so the same handler is valid on Marten, Polecat, Fisher, RavenDb or EF Core. The seam is a new optional IPersistenceFrameProvider.TryBuildFirstOrDefaultFrame, a default interface method returning false in the same shape as the existing TryBuildFetchSpecificationFrame. Each provider supplies its own frame, because every one of them spells the async terminal operator differently. Those extension classes and methods are referenced through typeof/nameof rather than as literal strings in the generated source, so a rename in any client library breaks the build instead of shipping a codegen failure that only surfaces at runtime -- which matters most for RavenDb, whose suite only runs on CI. Deliberately standalone rather than an IDataRequirement: no Required, no OnMissing. The parameter is null when nothing matches and the handler runs anyway, because a miss here is an ordinary answer rather than an error worth a 404. The query is unfiltered; a predicate is what Before methods, compiled queries and [FromQuerySpecification] are for. CosmosDb is NOT supported, and this is a storage model limitation rather than an omission: Wolverine's CosmosDb integration upserts every user document into one shared `wolverine` container alongside its own envelopes and node records, with no per-type discriminator on user documents, so "the first document of type T" cannot be asked for without risking a different type entirely. It therefore does not implement the new method, and the default false turns into a bootstrapping time error naming the provider rather than a wrong answer at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
This was referenced Aug 14, 2026
This was referenced Aug 22, 2026
Open
This was referenced Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[Entity]needs an identity to load by, so it cannot express the singleton document — a type a system stores exactly one of, looked up by nothing at all. Today that forces handler code to name a session type:becomes
The seam
A new optional
IPersistenceFrameProvider.TryBuildFirstOrDefaultFrame, a default interface method returningfalsein exactly the shape of the existingTryBuildFetchSpecificationFrame— so no external implementer breaks, and a provider that cannot support it says so rather than silently doing nothing.Each provider supplies its own frame, because every one of them spells the async terminal operator differently:
Marten.QueryableExtensions.FirstOrDefaultAsync(session.Query<T>(), token)Polecat.Linq.PolecatQueryableExtensions.FirstOrDefaultAsync(...)Fisher.Linq.QueryableExtensions.FirstOrDefaultAsync(...)Raven.Client.Documents.LinqExtensions.FirstOrDefaultAsync(...)Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(dbContext.Set<T>(), token)Those extension classes and methods are referenced through
typeof(...)/nameof(...)rather than as literal strings in the generated source. A rename in any client library then breaks this build rather than shipping a codegen failure that only surfaces the first time a handler using the attribute is compiled at runtime. That mattered enough to be worth the ceremony given RavenDb's suite only runs on CI.Deliberately not an
IDataRequirementNo
Required, noOnMissing, noMissingMessage. The parameter isnullwhen nothing matches and the handler runs anyway, because a miss here is an ordinary answer to "is there one of these yet?" rather than an error worth a 404. Callers write their own?? new T().The query is unfiltered on purpose — a predicate is what
Beforemethods, compiled queries and[FromQuerySpecification]are for, and promising consistent ordering across five different LINQ providers is not something this should try to do.CosmosDb is not supported, and that is a storage model limit
Worth review attention, because it is the one place this does not deliver all six stores.
Wolverine's CosmosDb integration upserts every user document into a single shared
wolverinecontainer — the same one holding its own incoming/outgoing envelopes, node records and locks — via a plaincontainer.UpsertItemAsync(document)with no per-type discriminator written onto user documents. Wolverine's own system documents are separated by apartitionKeyproperty; user documents have none.So "the first document of type
T" is not expressible there.SELECT * FROM c OFFSET 0 LIMIT 1would return an arbitrary item, quite possibly one of Wolverine's own envelopes, and deserialize it asT. Filtering toNOT IS_DEFINED(c.partitionKey)would exclude Wolverine's records but still conflate every user document type with every other.Rather than ship something that returns the wrong object, the CosmosDb provider simply does not implement the new method. The default
falsebecomes a bootstrapping timeInvalidOperationExceptionnaming the provider and pointing at aBeforemethod instead. Documented with a warning admonition.If we want Cosmos parity later, it needs a type discriminator stamped on user documents at write time — a storage format change, and its own PR.
Testing
A character-for-character identical handler in five suites, proving the storage agnostic claim rather than asserting it:
Each covers both the null case (nothing stored, handler still runs, writes its fallback) and the hit case.
One real behavior difference surfaced while writing these: on Fisher, a document table is created lazily on first write, and querying a type never written throws
no such tablerather than returning nothing. That is a general Fisher characteristic —storage_attribute_routes_to_fisher_storealready leans on it to assert a negative — not something this attribute introduces, so the Fisher test establishes the table then empties it, and the docs carry a tip about it.dotnet build wolverine.slnx -c Release -f net9.0clean.Docs
docs/guide/handlers/persistence.md— a new "Reading the First of a Type" section with the before/after, the always-optional semantics, the CosmosDb warning, and the Fisher tip.🤖 Generated with Claude Code
https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC