Skip to content

Source-generate self-aggregating records from their own declaration (marten#4557)#367

Merged
jeremydmiller merged 1 commit into
mainfrom
fix/4557-self-aggregating-records
May 25, 2026
Merged

Source-generate self-aggregating records from their own declaration (marten#4557)#367
jeremydmiller merged 1 commit into
mainfrom
fix/4557-self-aggregating-records

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Problem

Downstream report JasperFx/marten#4557: a user upgrading 8 → 9 has a self-aggregating immutable record with static Create/Apply, registered via Projections.Snapshot<MyType>(Inline). It throws at DocumentStore.For(...):

InvalidProjectionException: No source-generated dispatcher found for
SingleStreamProjection<MyType, System.Guid>...

Root cause

The generator's candidate predicate treats records differently from classes:

  • A class with conventional Apply/Create is discovered by Pipeline 1 from its own declaration and gets a free-standing evolver + [assembly: GeneratedEvolver(...)].
  • A record was filtered in IsCandidateClass to Evolve/EvolveAsync only (a fossil of the removed runtime-reflection path), so a self-aggregating record only ever got an evolver when the generator could see a Snapshot<T> / AggregateStream<T> call site (Pipeline 3).

That asymmetry is the root of the cross-assembly gap: a record aggregate defined in a domain library but registered in a separate composition-root assembly never has a [GeneratedEvolver] emitted into its own assembly — which is exactly where the runtime scans (tryUseAssemblyRegisteredEvolver keys on typeof(TDoc).Assembly). It also creates the impression that such a record must be partial, which it must not.

Fix

  • AggregateEvolverGenerator.IsCandidateClass: treat records identically to classes. A self-aggregating record with Apply/Create now gets its evolver emitted from its own declaration — no Snapshot<T> call site required, and never requiring partial. ExecuteCombined's existing dedup keeps same-assembly registrations single-emit (Pipeline 1 claims the type; Pipeline 3 skips it), exactly as classes already behave.
  • Message accuracy: corrected AggregateApplication.MissingDispatcherMessage and the EvolveAsync backstop — dropped the blanket "must be partial" claim (only projection subclasses need it), pointed at the assembly that defines the aggregate as the analyzer/lookup site, noted the generator ships inside the Marten package, and removed the dead docs/codegen/aot.md link.

Guiding principle: self-aggregating types (class or record) must never have to be partial — only projection subclasses do.

Validation

  • JasperFx.Events.SourceGenerator.Tests: 19/19 green, including a new self_aggregating_record_emits_evolver_from_its_own_declaration_without_partial (non-partial record, no Snapshot<T> call site → asserts the evolver + [GeneratedEvolver] attribute are emitted, no errors).
  • EventTests compiles clean (0 errors) under the new generator.

Downstream

JasperFx/marten#4557. Marten separately bundles this analyzer in its own NuGet package so package consumers get the generator automatically; once a generator release carries this change, Marten's bundled analyzer picks up the record/cross-assembly fix.

🤖 Generated with Claude Code

…ation

Self-aggregating types must never have to be `partial`. Classes with conventional
Apply/Create were already discovered by Pipeline 1 from their own declaration and got
a free-standing evolver + [assembly: GeneratedEvolver]. Records, however, were filtered
in IsCandidateClass to Evolve/EvolveAsync only (a fossil of the removed runtime
reflection path), so a self-aggregating *record* only ever got an evolver when the
generator could see a Snapshot<T>/AggregateStream<T> call site (Pipeline 3).

That asymmetry is the root of marten#4557's cross-assembly gap: a record aggregate
defined in a domain library but registered in a separate composition-root assembly
never had a [GeneratedEvolver] emitted into its own assembly — which is exactly where
the runtime (tryUseAssemblyRegisteredEvolver) scans, keyed on typeof(TDoc).Assembly.

Fix: treat records identically to classes in the candidate predicate. A self-aggregating
record with Apply/Create now gets its evolver emitted from its own declaration, with no
Snapshot<T> call site required and without being declared `partial`. ExecuteCombined's
existing dedup keeps same-assembly registrations single-emit (Pipeline 1 claims the type;
Pipeline 3 skips it), matching how classes already behave.

Also corrects the misleading "No source-generated dispatcher found" message in
AggregateApplication + the EvolveAsync backstop: drops the blanket "must be partial"
claim (only projection subclasses need it), points at the assembly that defines the
aggregate as the analyzer/lookup site, notes the generator ships in the Marten package,
and removes the dead docs/codegen/aot.md link.

SG suite 19/19 (incl. new self_aggregating_record_emits_evolver_from_its_own_declaration
_without_partial); EventTests compiles clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit e2b62fe into main May 25, 2026
1 check passed
jeremydmiller added a commit that referenced this pull request May 25, 2026
Patch release carrying #367 (marten#4557): the source generator now emits a
self-aggregating evolver for `record` aggregates from their own declaration
(parity with classes — no Snapshot<T> call site and no `partial` required),
and the "No source-generated dispatcher found" message is corrected.

Co-Authored-By: Claude Opus 4.7 (1M context) <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.

1 participant