Skip to content

F# code generation Phase B: in-memory saga frames (GH-2969)#2973

Merged
jeremydmiller merged 1 commit into
feat-2969-fsharp-codegen-foundationfrom
feat-2969-fsharp-phase-b
May 29, 2026
Merged

F# code generation Phase B: in-memory saga frames (GH-2969)#2973
jeremydmiller merged 1 commit into
feat-2969-fsharp-codegen-foundationfrom
feat-2969-fsharp-phase-b

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Phase B of the F# code-generation audit (#2969). Teaches the in-memory stateful saga frame set to emit F#, and renders a real saga's start + continue chains to compilable F#.

Stacked on #2971 (which now carries foundation + Phase A). Base is feat-2969-fsharp-codegen-foundation; this rebases onto main once that lands. Diff below is Phase B only.

A string-keyed CountingSaga (Start + Handle) drives the fixture; the driver renders both its chains alongside the Phase A handlers.

Saga frame F# overrides

  • CreateNewSagaFramelet saga = T().
  • ResolveSagaFrame / SagaStoreOrDeleteFrame / ConditionalSagaInsertFrame — composites that delegate to their inner frames' F# and render the surrounding if/else (IsCompleted() → delete/update; not-completed → insert).
  • SetSagaIdFrame / SetSagaIdFromSagaFrameSetSagaId + a null-guarded SetTag block (no ?.; pipe SetTag to ignore).
  • AssertSagaStateExistsFrameraise (UnknownSagaException(typeof<T>, sagaId)).
  • PullSagaIdFromMessageFrame / PullSagaIdFromEnvelopeFrame — the saga-id resolvers. The string branch uses if isNull x then envelope.SagaId else x; the Guid/numeric branch uses F#'s auto-tupled match Type.TryParse(s) with | true, v -> … | _ -> …, with Unchecked.defaultof<T> as the zero/default sentinel.

In-memory Load/Store/Delete are plain MethodCalls (already emit F#); the not-found null-guard comes from JasperFx's IfElseNullGuardFrame (already F#).

Generated F# (excerpt — the saga continue chain)

let incrementCount = context.Envelope.Message :?> IncrementCount
let sagaId = if isNull incrementCount.Id then context.Envelope.SagaId else incrementCount.Id
if System.String.IsNullOrEmpty(sagaId) then
    raise (IndeterminateSagaStateIdException(context.Envelope))
let countingSaga = _inMemorySagaPersistor.Load<CountingSaga>(sagaId)
if isNull countingSaga then
    raise (UnknownSagaException(typeof<CountingSaga>, sagaId))
else
    context.SetSagaId(sagaId)
    countingSaga.Handle(incrementCount)
    if countingSaga.IsCompleted() then
        _inMemorySagaPersistor.Delete<CountingSaga>(sagaId)
    else
        _inMemorySagaPersistor.Store<CountingSaga>(countingSaga)

Verification

  • Compile-gate test — the regenerated Generated.fs (4 handler chains + 2 saga chains) compiles via dotnet build.
  • fsharp-coverage24 implemented / 2 skipped / 18 remaining of 44 (up from 15/2/27 at Phase A).
  • dotnet build wolverine_fsharp.slnx -c Release — clean.
  • Full dotnet build wolverine.slnx -c Release regression — 0 warnings, 0 errors.

Deferred (not reachable from a minimal in-memory saga, so not exercised here)

  • CreateMissingSagaFrame (reassigns the loaded-saga var → needs the load Variable marked Mutable so F# renders let mutable; only used for start-or-continue / NotFound).
  • Store-specific operation wrappers LoadSagaOperation / SagaOperation / EnrollAndFetchSagaStorageFrame<,>.
  • ShouldProceedGuardFrame (ResequencerSaga).
  • The Marten/Polecat data-requirement frames (ThrowRequiredDataMissingExceptionFrame, ConstructSpecificationFrame, …) — separate assemblies; need per-store fixture projects (the issue's open per-store-granularity question).
  • The behavioural run-step (TypeLoadMode.Static + InvokeAsync + assert) — Phase B stays at compile-gate depth for now, like Phase A.

Part of #2969.

🤖 Generated with Claude Code

Phase B of the F# code-generation audit (#2969): teach the in-memory stateful
saga frame set to emit F#, and render a real saga's start + continue chains to
compilable F#. A string-keyed CountingSaga (Start + Handle) drives the fixture.

Saga frame F# overrides:
- CreateNewSagaFrame: `let saga = T()`.
- ResolveSagaFrame / SagaStoreOrDeleteFrame / ConditionalSagaInsertFrame:
  composites that delegate to their inner frames' F# and render the surrounding
  `if/else` (IsCompleted -> delete/update; not-completed -> insert).
- SetSagaIdFrame / SetSagaIdFromSagaFrame: SetSagaId + a null-guarded SetTag
  block (no `?.`, pipe SetTag to ignore).
- AssertSagaStateExistsFrame: `raise (UnknownSagaException(typeof<T>, sagaId))`.
- PullSagaIdFromMessageFrame / PullSagaIdFromEnvelopeFrame: the saga-id
  resolvers. The string branch uses `if isNull x then envelope.SagaId else x`;
  the Guid/numeric branch uses F#'s auto-tupled `match Type.TryParse(s) with
  | true, v -> ... | _ -> ...`, with Unchecked.defaultof<T> as the zero/default
  sentinel.

In-memory Load/Store/Delete are plain MethodCalls (already emit F#); the
not-found null-guard comes from JasperFx's IfElseNullGuardFrame (already F#).

fsharp-coverage after Phase B: 24 implemented / 2 skipped / 18 remaining of 44
Frame types in Wolverine.dll (up from 15/2/27 at Phase A).

Deferred to a Phase B follow-up (not reachable from a minimal in-memory saga, so
not exercised here): CreateMissingSagaFrame (reassigns the loaded-saga var; needs
the load variable marked mutable), the store-specific LoadSagaOperation /
SagaOperation / EnrollAndFetchSagaStorageFrame wrappers, ShouldProceedGuardFrame
(ResequencerSaga), the Marten/Polecat data-requirement frames (separate
assemblies / per-store fixtures), and the behavioural run-step (TypeLoadMode.Static).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 3b30b10 into feat-2969-fsharp-codegen-foundation May 29, 2026
This was referenced Jun 1, 2026
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