Skip to content

Carry a projection's published types on SubscriptionDescriptor (#1102) - #1106

Merged
jeremydmiller merged 2 commits into
mainfrom
feat/1102-descriptor-published-types
Nov 2, 2026
Merged

jeremydmiller merged 2 commits into
mainfrom
feat/1102-descriptor-published-types

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #1102.

SubscriptionDescriptor only said what a projection produces through AggregateType, which is null for event projections and composites. A tool reading the descriptor couldn't link those projections to the documents they write, even though PublishedTypes() already knew.

What changes

  • SubscriptionDescriptor.PublishedTypes is a new TypeDescriptor[]: distinct, in ordinal order by full name, and never null. AggregateType is untouched.
  • IPublishesTypes is a new non-generic interface, so the descriptor constructor needs no generics. Every IProjectionSource<,> gets it through a default body, and PublishedTypes() stays declared on IProjectionSource<,> with new, so stores compiled against it and explicit implementers keep working.
  • A real bug in the scoped wrapper. ProjectionSourceWrapperBase hid its types behind an explicit interface implementation beside ProjectionBase's public method, and its Describe() reports a freshly resolved instance that never saw the registration's Options. It now overrides the method and supplies the list itself.
  • InMemoryDocumentStore.TryCreateUsage now calls Projections.Describe. Without it the store reported no projections at all and couldn't pass the new compliance check.
  • Compliance: six new facts in EventProjectionRegistrationCompliance.

Decisions worth checking

  • Empty does not mean "writes nothing". A subscription writes nothing, but a flat table projection writes rows with no type, and a bare IProjection never says. The docs say so.
  • Describing can't fail because of this field. A PublishedTypes() that throws or returns null gives an empty list rather than losing the whole Describe() pipeline, the same call the descriptor makes for ImplementationType. That case looks the same as an empty answer.
  • void, Task and ValueTask are filtered out. An event projection records Create return types in its constructor, ahead of validation, so they can appear on a projection nothing has validated yet.

The issue's open question: EF Core projections

  • Single and multi stream EF Core projections return TDoc, the entity type, in Marten and Polecat. Fisher has no such classes, but an ordinary projection routed with ProjectToEfCore also returns TDoc.
  • EfCoreEventProjection returns nothing in all three stores unless something declares a type. Marten's isn't a ProjectionBase, so only a registration-time teardown rule can add one.

So a consumer can resolve single and multi stream projections against DbContext entity lists, but not EF event projections. Other entities a projection touches through its DbContext are never listed.

Release note

  • New SubscriptionDescriptor.PublishedTypes and IPublishesTypes. IProjectionSource<,> now inherits IPublishesTypes.
  • On scoped source wrappers, PublishedTypes() called through a ProjectionBase reference now returns the full set. It used to return only Options.StorageTypes.
  • TryCreateUsage on the in-memory store now lists its projections.

Additive on the wire: an old payload or a null field reads as empty, and an older reader ignores the field. Both are tested.

Testing

Full gate on this branch, locally on net9.0 and net10.0: 7,808 passed, 0 failed, 66 skipped. It ran before #1103 merged; this branch merges cleanly with main, but the combination hasn't been run. Each guard was mutation-tested, and all 8 mutants were killed. One survived at first because assembly copied types into Options.StorageTypes, so its test was made stronger.

The compliance suite ships as source, so it was validated against real stores from a local pack (2.97.1-local.1102):

Store Backend event_projection_registration_compliance, net10.0 and net9.0
Fisher SQLite 9 of 9 on each
Polecat SQL Server 9 of 9 on each
Marten Postgres 15.3 9 of 9 on each
  • Removing Describe from each store made exactly the 6 new facts fail, so the passes aren't vacuous.
  • Fisher's whole compliance folder on net10.0: 757 passed, 0 failed, 22 skipped.
  • Binary compatibility: an assembly built against released 2.97.0 loaded and described itself beside this branch for three source shapes, and throws TypeLoadException if the default body is removed.

No store needs a change.

Not covered

  • A store's CompositeIProjectionSource and its scoped registrations can't be reached by a store-neutral suite. Those were checked by reading only.
  • Marten ran on Postgres 15.3, not its compose image (Postgres 17 with NAMEDATALEN 100). Polecat's and Marten's wider suites weren't run, only this class.
  • CritterWatch and Wolverine, as readers of the descriptor, weren't checked.
  • The in-memory store's usage.Events and RegisteredEventTypes stay empty, and it stays unenrolled from the explorer suite.

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits November 2, 2026 06:15
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>
@jeremydmiller
jeremydmiller merged commit 7864afa into main Nov 2, 2026
2 checks passed
erdtsieck pushed a commit to erdtsieck/jasperfx that referenced this pull request Nov 3, 2026
…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 JasperFx#1106 dead code, so that is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SubscriptionDescriptor should carry a projection's published types

1 participant