Repository navigation
Describe a projection as it was registered; keep nothing-to-store types out of teardown - #1108
Merged
Merged
Conversation
AggregateType only answers for an aggregation, so an event projection or a composite gave a tool nothing to link it to the documents it writes, although PublishedTypes() already knew. The descriptor is built from the non-generic ISubscriptionSource, which cannot reach that generic member, so a small IPublishesTypes interface does, the way IAggregateProjection does for the aggregate. Every IProjectionSource is one through a default implementation, so no store or third-party source has anything to change, including one that implements PublishedTypes() explicitly. The member stays declared on IProjectionSource because stores compile against it there. The wrappers were the risk, and one was wrong. ProjectionSourceWrapperBase kept its copy of the source's types behind an explicit interface implementation beside ProjectionBase's public method, which never saw them. An interface binds to that public method on anything deriving from ProjectionBase, so a scoped projection read as writing only what was in Options.StorageTypes. Assembly hid this by copying the types there as teardown targets, until a projection opted out of teardown. It is an override now. Describe() on the same wrapper also reports a newly resolved instance, which has never seen the Options configured at registration, so the wrapper supplies the list itself. The value is read at describe time on projections nothing may have validated, so it filters the void and Task return types an event projection records from its constructor, and a PublishedTypes() that throws leaves the list empty rather than losing the whole Describe() pipeline. It is sorted by full name so that a descriptor does not reorder between runs, and a payload with no such field, or a null one, reads as empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every store wraps projection sources of its own, and a store that left PublishedTypes empty would look the same as one whose projections write nothing, so the check asks each store through TryCreateUsage for one of each kind. It lives in EventProjectionRegistrationCompliance, the suite about what a projection publishes, because the in-memory store can run that one and cannot take the explorer suite's async registration. The composite and subscription facts need a daemon, so they skip there. Running it found that the in-memory store's TryCreateUsage never called Projections.Describe, so it read as a store with no projections at all. That is fisher#120 again, and Marten, Polecat and Fisher all make the call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es out of teardown Three gaps in how projections describe themselves, each reproduced with a failing test first. A container-scoped source wrapper described a freshly resolved instance, so the name, version, lifecycle, event filters and shards set at registration were lost: an Inline projection reported Async with daemon metrics, and a renamed one reported a shard that does not run. The wrapper now overlays what registration decided onto that description. CompositeProjection.PublishedTypes() dropped the composite's own declarations. It now includes them after its members. Fisher's override did this for Fisher alone (fisher#63) and is now redundant. An event projection recorded void, Task and ValueTask as published types from its constructor. A generated dispatcher skips method validation and the generator drops such a method, so these reached Options.CleanUps on a projection that passed validation. They now stop at EventProjectionApplication.PublishedTypes(), which makes the descriptor-only filter from #1106 dead code, so that is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeremydmiller
changed the base branch from
feat/1102-descriptor-published-types
to
main
November 2, 2026 12:48
This was referenced Nov 2, 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.
Three problems found next to #1106 in how projections describe themselves. All three were real: 16 of the new tests failed on the unfixed code, and the other new tests pin behaviour that was already right.
Stacked on #1106, which is still open, so this targets
feat/1102-descriptor-published-types. Retarget this PR tomainbefore merging #1106. Deleting that branch would close this PR.1. A scoped projection reported values from an instance registration never touched
ProjectionSourceWrapperBase.Describeresolved a new instance and described that. Marten and Fisher setLifecycleand runconfigureon the wrapper, and the wrapper is what runs, so the description was of something else:Lifecycle = InlineAsync, with daemon metrics and spans that never existName = "RenamedAtRegistration"MyAggregate, and a shard name that doesn't runVersion = 31IncludeType<BEvent>()AppliedEventsEventStoreUsage.PopulateAgentUrisreadsLifecycleandShardNames, so an Inline projection also got an agent URI for an agent that doesn't exist, and a renamed one got a URI under the wrong name.The wrapper still calls
source.Describe(store), then overlays what registration decided through one internalSubscriptionDescriptor.ReadRegistrationFrom. This replaces thePublishedTypesline #1106 added. Rebuilding from the wrapper was the alternative, butDescribeis virtual on an event projection and that would lose anything a projection adds to its own description. There is a test for that.This affects Marten and Fisher. Polecat has no scoped wrappers.
2.
CompositeProjection.PublishedTypes()dropped the composite's own declarationsThe override replaced the base and returned only the members' types, although the
SubscriptionDescriptor.PublishedTypesdocs already promised these. The base now does whatFisherCompositeProjectiondid, with two differences:base.PublishedTypes(), so a type given toRegisterPublishedTypeon a composite counts too.PublishedTypes().FirstOrDefault()as a projection's state type.Store sources read at origin heads: marten
2f2769bca, polecat8545249, fisher3f4c4be. Only Fisher overrides this, and it stays correct, just redundant. Wolverine has no subclass.This is more than a descriptor change, because
PublishedTypes()has other readers. For a composite that names a type for itself, which should be rare:AllPublishedTypesnow materialises a mapping for that type, andProjectionScenariowipes it. Its rebuild teardown readsCleanUpsand doesn't change.ConfiguredDocumentProvidersnow creates a provider for it.CollectTeardownTargetsaddsPublishedTypes()before it checksTeardownDataOnRebuild, so a composite with its own rule andTeardownDataOnRebuild = falsenow wipes that table. Polecat already treats ordinary projections this way, but this is the one change that could surprise someone.AsyncShardsPublishingTypeandRebuildProjectionAsync(Type)can now resolve that type to the composite, andTryFindAggregatestays null-safe.3. Teardown needed the guard, and the path is worse than the failed-validation case
Registering
voidbefore validation fails is harmless by itself, because the projection never gets registered. But a generated dispatcher skips method validation entirely, and the analyzer silently drops aCreateorTransformthat returnsvoid,TaskorValueTask. A partial projection with one good method and one bad one therefore passes validation with:The same list went into
StorageTypesandPublishedTypes(), so each store was handedSystem.Voidas a document type.The guard now sits in
EventProjectionApplication.PublishedTypes(), where the list starts, so it covers all of those readers. The filter #1106 added to the descriptor became dead code and is removed.Not bugs
ScopedProjectionWrapperdescribes itself, so it always reported what was registered. It is pinned by a test. Its// TODO -- some way to understand the lifecycleis out of date.AppliedEventson a scoped aggregation matches the unwrapped projection unless registration changes the filter.Task<T>andValueTask<T>fromCreatealready registered the document type, and now have a test.Gate
./build.sh Test --root "$PWD"passed on this commit, on net9.0 and net10.0, with no failures. Every target succeeded:Counts are per framework. The source generator, Aspire and MEAI suites and the smoke targets passed too.
CI has not run on this PR.
dotnet.ymlonly triggers for PRs targetingmain, so the empty check list is not a pass. It will run once this is retargeted, and it should be green before merging.Not run against a real store. The store effects above come from reading their sources. The compliance suite has no store-neutral way to reach a composite's
Optionsor a scoped registration, so these are unit tests only, and I left the suite alone rather than add a builder member each store would have to implement.Follow-ups, not done here
voidorTaskCreateunder a generated dispatcher never runs, and nothing says so. This PR stops the bad teardown target, but the method is still dropped silently while its event stays inIncludedEventTypes. Without the generator the same projection is refused at startup. It needs an analyzer diagnostic or validation that also covers generated dispatchers. This comes from reading the analyzer and emitter.FisherCompositeProjection.PublishedTypes()once Fisher pins a release with this.ScopedProjectionWrapperresetsVersionto 1 (or its[ProjectionVersion]) after copying the source's, so a version set in a projection's constructor is lost.ProjectionSourceWrapperBasekeeps it. This one is not a descriptor error, because the shard runs under the same number, but the two wrappers disagree. Also from reading only.🤖 Generated with Claude Code