From 0ac88ac5daed150e16f340fd1225c38660e48376 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Tue, 30 Jun 2026 12:31:21 -0500 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20align=20Aspire=20to=2013.4.6=20(SD?= =?UTF-8?q?K=20+=20Hosting)=20=E2=80=94=20close=20the=2013.3.5/13.4.3=20sk?= =?UTF-8?q?ew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppHost had two Aspire version knobs out of sync: the build-time Aspire.AppHost.Sdk at 13.3.5 and the runtime Aspire.Hosting.* packages at 13.4.3. Bump both to the current latest, 13.4.6, so they move together. - Aspire.AppHost.Sdk 13.3.5 → 13.4.6 (CritterMart.AppHost.csproj) - Aspire.Hosting.{AppHost,JavaScript,PostgreSQL,RabbitMQ} 13.4.3 → 13.4.6 - de-stale a Program.cs comment that named the exact patch (13.4.3) No CritterWatch/Wolverine clash: Aspire.Hosting.* is orthogonal to the CW↔Wolverine coupling (CW depends on WolverineFx 6.12.0, not Aspire); the only shared axis is the .NET 10 line, which 13.4.6 stays on. The two impactful 13.4 breaking changes (Postgres default image 17.6→18.3, RabbitMQ 4.2→4.3) already shipped with the 13.4.3 Hosting packages and are already proven — CritterMart's Postgres is ephemeral (no WithDataVolume, no data-volume break). Live-verified on the full Aspire stack: clean build (0 warnings), clean boot (all three services healthy, no errors in the AppHost log), correct container images (postgres:18.3 + rabbitmq:4.3, both crittermart-labeled), seeder ran (crit-rare = 3), and the full Orders→RabbitMQ→Inventory cross-BC saga flow exercised via demo-traffic.ps1 -Backorder -Cover (saga opened + resolved, crit-rare 3→10). Co-Authored-By: Claude Opus 4.8 --- Directory.Packages.props | 8 ++++---- src/CritterMart.AppHost/CritterMart.AppHost.csproj | 2 +- src/CritterMart.AppHost/Program.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 26c8128..38caca0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,10 +4,10 @@ - - - - + + + + diff --git a/src/CritterMart.AppHost/CritterMart.AppHost.csproj b/src/CritterMart.AppHost/CritterMart.AppHost.csproj index 9def52f..c2973cd 100644 --- a/src/CritterMart.AppHost/CritterMart.AppHost.csproj +++ b/src/CritterMart.AppHost/CritterMart.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe diff --git a/src/CritterMart.AppHost/Program.cs b/src/CritterMart.AppHost/Program.cs index 885d899..b8dd9c1 100644 --- a/src/CritterMart.AppHost/Program.cs +++ b/src/CritterMart.AppHost/Program.cs @@ -8,7 +8,7 @@ // Only Postgres + RabbitMQ are containers here — the three services and the seeder are dotnet // processes and the storefront is a node process, so they never appear in Docker Desktop's // container list. WithContainerRuntimeArgs passes the --label straight through to the runtime. -// Mirrors CritterBids' AppHost (same Aspire 13.4.3). +// Mirrors CritterBids' AppHost (both on the Aspire 13.4 line). const string dockerProject = "crittermart"; // Shared PostgreSQL with one database; each service uses its own schema (ADR 002). From 83bedb5e82fa03e1b6be073f1a164f0f980e7ea1 Mon Sep 17 00:00:00 2001 From: Erik Shafer Date: Tue, 30 Jun 2026 13:17:12 -0500 Subject: [PATCH 2/2] chore: upgrade CritterWatch to 1.0.0-beta.1 + WolverineFx to 6.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beta.1 is built against GA WolverineFx 6.16.0 (read from its nuspec), which satisfies the "revisit when a GA-Wolverine CW ships" condition (alpha.4's prerelease 6.14 line was why alpha.4 was skipped). Moving Wolverine past 6.13.x straight to 6.16.0 atomically with CritterWatch is the sanctioned coupling path and obsoletes the stranded Dependabot 6.13.x bumps. Marten 9.12.0 + JasperFx.Events 2.18.1 come transitively. MessagePack CVE (GHSA-hv8m-jj95-wg3x) still unresolved on beta.1 (transitive 2.5.302 < 3.0.214) — suppression kept, comment re-dated. - Directory.Packages.props: CritterWatch + Wolverine.CritterWatch alpha.3 → beta.1; all 8 WolverineFx.* 6.12.0 → 6.16.0; coupling + MessagePack comments rewritten. Necessitated Marten-9.12 compat fix (the bump was not zero-code): Marten 9.12 re-materializes instance-registered inline projections, dropping constructor-injected state — the two "awaiting-list" Bruun projections read their captured timeout back as default(TimeSpan), so the view's visible Deadline silently became "now". Cosmetic only (cancellation/abandonment run off the scheduled messages + the streams, not this view). Fixed by making both projections stateless fact-recorders (store PlacedAt / LastActivityAt) and computing the deadline on read in the GET endpoints (which inject the existing PaymentDeadline / CartActivityDeadline singletons). HTTP contract (responses carry Deadline) preserved. Verified: build clean (0 warnings); 149/149 tests green (the 2 bump-introduced failures fixed); full live boot on the Aspire stack — CritterWatch console up clean (no startup TypeLoadException → coupling correct) and the refactored awaiting-payment deadline reads ~7 min in the future, not "now". See docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md. Co-Authored-By: Claude Opus 4.8 --- Directory.Packages.props | 50 ++++++++--------- .../004-critterwatch-next-release-upgrade.md | 54 +++++++++++++++++++ src/CritterMart.Orders/Features/AddToCart.cs | 11 ++-- src/CritterMart.Orders/Features/PlaceOrder.cs | 11 ++-- .../Ordering/OrdersAwaitingPayment.cs | 28 ++++++---- src/CritterMart.Orders/Program.cs | 19 +++---- .../Shopping/CartsAwaitingActivity.cs | 40 ++++++++------ .../CartAbandonmentTests.cs | 4 +- .../CartsAwaitingActivityProjectionTests.cs | 31 +++++------ .../OrdersAwaitingPaymentProjectionTests.cs | 20 +++---- .../PaymentTimeoutTests.cs | 4 +- 11 files changed, 177 insertions(+), 95 deletions(-) create mode 100644 docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 38caca0..3e1dc8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,23 +19,25 @@ + PINNED AT 6.16.0 to match the CritterWatch line installed below. CritterWatch 1.0.0-beta.1 is + compiled against WolverineFx(.Marten) 6.16.0; running a HIGHER WolverineFx than the version + CritterWatch targets throws a startup TypeLoadException in Wolverine.Marten's projection-distribution + internals (EventSubscriptionAgentFamily.TryRebuildRegisteredProjectionAsync). There is only one + Wolverine.Marten per process, so the console and the four services must share the exact version + CritterWatch was built against. Bump these ONLY in lockstep with a CritterWatch release + (CritterWatch + Wolverine.CritterWatch + WolverineFx.* move together, never apart) — diagnosed + 2026-06-24 (CW alpha.3 / 6.12.0), updated 2026-06-30 (CW beta.1 / 6.16.0); see the dependency-update + runbook. This supersedes the prior "held at 6.12.0, no 6.13.x" pin: beta.1 ships against GA Wolverine + 6.16.0, so Dependabot's stranded 6.13.x bumps (#94/#99) are now obsolete. --> - - - - - - - - + + + + + + + + + - - + + - + bump CritterWatch has not yet taken. Re-verified unaddressed on 1.0.0-beta.1 (2026-06-30; was + 2.5.301 on alpha.3). Suppress until an upstream CritterWatch release moves to MessagePack 3.x. --> diff --git a/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md new file mode 100644 index 0000000..cf2effd --- /dev/null +++ b/docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md @@ -0,0 +1,54 @@ +--- +retrospective: 004 +kind: chore +prompt: docs/prompts/chore/004-critterwatch-next-release-upgrade.md +deliverable: Directory.Packages.props (CritterWatch + Wolverine.CritterWatch alpha.3 → beta.1; WolverineFx.* 6.12.0 → 6.16.0; coupling + MessagePack-CVE comment rewrites); src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs + Shopping/CartsAwaitingActivity.cs (stateless projections + read-shaped row records); src/CritterMart.Orders/Features/PlaceOrder.cs + AddToCart.cs (read-time deadline endpoints); src/CritterMart.Orders/Program.cs (generic projection registration + comments); tests/CritterMart.Orders.Tests/{OrdersAwaitingPaymentProjectionTests,CartsAwaitingActivityProjectionTests,PaymentTimeoutTests,CartAbandonmentTests}.cs; docs/retrospectives/chore/004-critterwatch-next-release-upgrade.md (this file) +date: 2026-06-30 +mode: solo maintenance; CritterWatch coupling per Directory.Packages.props + the dependency-update runbook; Marten-9.12 compat fix added when the bump surfaced a projection regression +session-runner: Claude (Opus 4.8) +--- + +# Retrospective — Chore 004: CritterWatch next-release upgrade (→ beta.1 / Wolverine 6.16.0) + +## Outcome summary + +CritterWatch moved **`1.0.0-alpha.3` → `1.0.0-beta.1`** and, in lockstep, the whole Critter Stack line moved **WolverineFx.* `6.12.0` → `6.16.0`** (Marten `9.12.0` + JasperFx.Events `2.18.1` pulled transitively). beta.1's nuspec was read first-hand — it targets **GA WolverineFx 6.16.0**, not a prerelease alpha line, which is exactly the condition Erik set for taking the upgrade ("revisit when a GA-Wolverine CW ships"; alpha.4's prerelease-6.14 line was the reason alpha.4 was skipped). This **resolves the standing "DO NOT merge Wolverine 6.13.x" warning** the sanctioned way — past 6.13.x straight to 6.16.0, atomically with CritterWatch — so the stranded Dependabot 6.13.x bumps are obsolete. The MessagePack CVE (`GHSA-hv8m-jj95-wg3x`) is **still unresolved on beta.1** (transitive MessagePack `2.5.302` < the `3.0.214` fix), so the audit suppression stays, comment re-verified-dated. + +**The bump was not zero-code.** Marten 9.12 surfaced a projection regression: an inline `SingleStreamProjection` registered as an *instance* (`Projections.Add(new T(arg), …)`) is re-materialized by the runtime, so **constructor-injected state is dropped** — the two "awaiting-list" Bruun todo-list projections read their captured timeout back as `default(TimeSpan)`, so the view's visible `Deadline` silently became "now". Fixed by making both projections **stateless fact-recorders** (store `PlacedAt` / `LastActivityAt`) and moving the deadline policy to the **read side** (the GET endpoints inject the existing `PaymentDeadline` / `CartActivityDeadline` singletons and compute `Deadline = timestamp + duration`). Impact was cosmetic-only — the cancellation/abandonment decisions run off the scheduled messages and the Order/Cart streams, never this view. + +Verified three ways: full solution build (0 warnings / 0 errors); **149/149 tests green** (the two integration failures the bump introduced are fixed, nothing else regressed); and a **full live boot** on the real Aspire stack — all three services + the **CritterWatch console came up clean** (PID confirmed; the coupling crash is a startup `TypeLoadException`, so a healthy boot is the proof), and the refactored `GET /orders/awaiting-payment` returned a deadline ~7 min in the future (the `Orders__PaymentTimeout` knob), not "now". + +## What worked + +- **Read the nuspec, don't guess the floor.** `curl`-ing `wolverine.critterwatch/1.0.0-beta.1/*.nuspec` showed `WolverineFx 6.16.0` (and `WolverineFx.Marten/Http.Marten/SignalR 6.16.0`, `Marten 9.12.0`) directly — turning "is beta.1 safe to take?" from a judgment call into a fact (GA vs prerelease), which is the whole gating question. +- **Empirical root-causing beat speculation.** The failure (`Deadline ≈ now` despite a 10-min config) had two plausible stories (TimeProvider-sourced `e.Timestamp` vs dropped timeout). The unit test passing (direct `new Projection(10min)`) while the integration test failed, plus a one-line hardcode probe (`Add(10min)` → green), isolated it to "Marten doesn't preserve the instance's ctor state" without needing the changelog (ctx7 was quota-blocked all session). +- **The fix is a better design, not just a workaround.** Stateless projection + read-time policy is the more idiomatic CQRS split (the view stores facts; the endpoint applies config). The bump forced the cleaner shape. +- **Live boot earned its keep.** Both the CW coupling (startup `TypeLoadException`) and the projection fix (a live deadline value) are runtime-only truths a green test summary wouldn't fully prove. + +## What was harder than expected + +- **The Marten 9.12 instance-projection-state change is undocumented-to-us and subtle.** It only manifests through the runtime projection path, so it slipped past the build and the unit tests — only the two integration tests that drive the projection through Marten caught it. Diagnosis took several iterations of arithmetic before the hardcode probe settled it. +- **ctx7 monthly quota was exhausted** (as the prior session flagged). Fell back to the official Aspire/Marten release notes via WebFetch and to first-hand nuspec + empirical probes — no training-data guessing, per the docs rule. + +## Methodology refinements that emerged + +1. **A Critter Stack dependency upgrade needs BOTH a full `dotnet test` and a live boot — they catch different failure classes.** The CW coupling is a startup crash (live boot only); the Marten projection-state change is a runtime behavior shift (integration tests only). A build + unit tests would have shipped both bugs. +2. **Prefer stateless projections; put per-projection config on the read side.** Constructor-injected projection state is fragile across Marten versions (it re-materializes projections). When a projection needs config, store the raw fact and apply the policy where DI lives (the endpoint), not in the projection instance. +3. **A "version bump" chore can legitimately grow a code fix when the bump breaks behavior** — bundling the fix is correct (the PR can't go green without it); the retro records the divergence from the prompt's "Directory.Packages.props only" plan. + +## Outstanding items / next-session inputs + +1. **MessagePack CVE still suppressed** — beta.1 transitively pulls MessagePack `2.5.302` (< `3.0.214`). Re-check on the next CritterWatch release; drop the `` when it moves to MessagePack 3.x. +2. **Consider reporting the Marten 9.12 instance-projection-state behavior upstream** to JasperFx (instance-registered inline projections losing constructor state). The CritterMart fix doesn't need it, but other Critter Stack apps using `Projections.Add(new T(arg), …)` would hit the same. +3. **Dependabot cleanup:** #104 (critter-stack group), #108 (CritterWatch alpha.4), #110 (Wolverine.CritterWatch alpha.4) are superseded by this PR — closed. #105 (aspire group) is superseded by the separate Aspire PR #116. #106 (otel), #107 (test-stack), #109 (Swashbuckle) remain as separate concerns. +4. **Post-talk:** the four AppHost demo knobs still want deleting after the talk (unchanged by this PR). + +## Spec-delta — landed? + +**None named, and none shipped** — this chore changes no domain behavior. The `Directory.Packages.props` coupling comment is the durable operational record of the new CW↔Wolverine pin. The read-time deadline refactor is an internal restructuring that **preserves the HTTP contract** (the awaiting-list responses still carry `Deadline`); no narrative, workshop, or OpenSpec capability changed, and the unchanged integration assertions (deadline in the future; row vanishes on terminal events) are the regression guard. The retro forward-confirms the named-none. + +## Process notes + +- One PR bundles `chore:` (the version coupling) and the Marten-9.12 compatibility fix it necessitated. +- Branch: `chore/critterwatch-beta1`. +- The upgrade crosses the previously-held 6.12.0 Wolverine pin to 6.16.0 — Erik's merge is the conscious blessing of that line move; the coupling comment + this retro record why it is now safe (CW beta.1 ships against GA 6.16.0). diff --git a/src/CritterMart.Orders/Features/AddToCart.cs b/src/CritterMart.Orders/Features/AddToCart.cs index f77432f..c559177 100644 --- a/src/CritterMart.Orders/Features/AddToCart.cs +++ b/src/CritterMart.Orders/Features/AddToCart.cs @@ -131,11 +131,16 @@ public static async Task Get(string cartId, IQuerySession session) // conditional deletes). A literal route segment, so it wins over /carts/{cartId} by ASP.NET // Core route precedence. [WolverineGet("/carts/awaiting-activity")] - public static async Task GetAwaitingActivity(IQuerySession session) + public static async Task GetAwaitingActivity(IQuerySession session, CartActivityDeadline deadline) { + // The view stores LastActivityAt; the visible Deadline is LastActivityAt + the configured window, + // applied here on read (the projection is stateless — see CartsAwaitingActivity remarks). Ordering + // by LastActivityAt equals ordering by Deadline since the window is constant across rows. var rows = await session.Query() - .OrderBy(x => x.Deadline) + .OrderBy(x => x.LastActivityAt) .ToListAsync(); - return Results.Ok(rows); + var result = rows.Select(r => + new CartAwaitingActivityRow(r.Id, r.CustomerId, r.LastActivityAt.Add(deadline.Duration))); + return Results.Ok(result); } } diff --git a/src/CritterMart.Orders/Features/PlaceOrder.cs b/src/CritterMart.Orders/Features/PlaceOrder.cs index e9fe068..7e330d5 100644 --- a/src/CritterMart.Orders/Features/PlaceOrder.cs +++ b/src/CritterMart.Orders/Features/PlaceOrder.cs @@ -117,11 +117,16 @@ public static async Task Get(string orderId, IQuerySession session) // live set of orders the payment-timeout automation is watching. A literal route segment, so // it wins over /orders/{orderId} by ASP.NET Core route precedence. [WolverineGet("/orders/awaiting-payment")] - public static async Task GetAwaitingPayment(IQuerySession session) + public static async Task GetAwaitingPayment(IQuerySession session, PaymentDeadline deadline) { + // The view stores PlacedAt; the visible Deadline is PlacedAt + the configured timeout, applied + // here on read (the projection is stateless — see OrdersAwaitingPayment remarks). Ordering by + // PlacedAt equals ordering by Deadline since the timeout is constant across rows. var rows = await session.Query() - .OrderBy(x => x.Deadline) + .OrderBy(x => x.PlacedAt) .ToListAsync(); - return Results.Ok(rows); + var result = rows.Select(r => + new OrderAwaitingPaymentRow(r.Id, r.CustomerId, r.Total, r.PlacedAt.Add(deadline.Duration))); + return Results.Ok(result); } } diff --git a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs index c07bbaa..0b60336 100644 --- a/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs +++ b/src/CritterMart.Orders/Ordering/OrdersAwaitingPayment.cs @@ -8,32 +8,40 @@ namespace CritterMart.Orders.Ordering; // when OrderPlaced folds and DELETED when any terminal event folds — a Marten conditional delete. // The timeout handler does NOT read this view (the Order stream is the single source of truth for // the cancellation decision; design.md Decision 3) — this is the observable face of the automation. +// +// The view stores PlacedAt (a fact: the OrderPlaced append time); the visible Deadline is computed at +// READ time in the endpoint as PlacedAt + the configured payment timeout. The deadline policy lives on +// the read side, NOT in the projection, on purpose: under Marten 9.x an inline projection registered as +// an instance is re-materialized by the runtime, so constructor-injected state (a captured TimeSpan) is +// lost and reads back as default(TimeSpan). Keeping the projection STATELESS sidesteps that — the +// timeout is config/DI, which the endpoint has and the projection does not. (Surfaced by the CW/Marten +// 9.12 upgrade, chore/004: the prior ctor-injected deadline silently became "now".) public class OrderAwaitingPayment { public string Id { get; set; } = string.Empty; // the orderId (stream key) public string CustomerId { get; set; } = string.Empty; public decimal Total { get; set; } - public DateTimeOffset Deadline { get; set; } // when OrderPaymentTimeout will fire + public DateTimeOffset PlacedAt { get; set; } // OrderPlaced append time; Deadline = PlacedAt + timeout (on read) } +// The read-shaped row returned by GET /orders/awaiting-payment: the stored PlacedAt projected forward by +// the configured payment timeout into the visible Deadline. Distinct from the stored view so the +// projection stays a pure fact-recorder and the policy (the timeout) is applied once, on read. +public record OrderAwaitingPaymentRow(string Id, string CustomerId, decimal Total, DateTimeOffset Deadline); + // Inline single-stream projection (ADR 008) over the same Order stream that OrderStatusView folds — // two projections, one stream: projection shape is a per-projection choice, and this one's is -// "a row exists only while the order is non-terminal". The configured PaymentDeadline duration is -// constructor-injected (instance registration in Program.cs) so the row's visible deadline matches -// the schedule PlaceOrder actually set. +// "a row exists only while the order is non-terminal". STATELESS by design (see the view remarks) — +// it records the placement timestamp; the endpoint adds the configured timeout on read. public partial class OrdersAwaitingPaymentProjection : SingleStreamProjection { - private readonly TimeSpan _paymentTimeout; - - public OrdersAwaitingPaymentProjection(TimeSpan paymentTimeout) => _paymentTimeout = paymentTimeout; - // The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention): - // the deadline is placement time plus the configured timeout. + // store it raw; the deadline is computed on read from the configured timeout. public void Apply(IEvent e, OrderAwaitingPayment view) { view.CustomerId = e.Data.CustomerId; view.Total = e.Data.Total; - view.Deadline = e.Timestamp.Add(_paymentTimeout); + view.PlacedAt = e.Timestamp; } // Conditional deletes (Marten 9 ShouldDelete method convention): any terminal event removes the diff --git a/src/CritterMart.Orders/Program.cs b/src/CritterMart.Orders/Program.cs index 2676ea3..e08733d 100644 --- a/src/CritterMart.Orders/Program.cs +++ b/src/CritterMart.Orders/Program.cs @@ -22,7 +22,7 @@ // The payment deadline (slice 4.7): how long a placed order may sit non-terminal before the // scheduled OrderPaymentTimeout cancels it. Config-driven so the Aspire demo can shorten it // (e.g. 30s) without recompiling; one value feeds both the PlaceOrder schedule and the -// OrdersAwaitingPayment projection's visible deadline. +// awaiting-payment endpoint's visible deadline (computed on read — the projection is stateless). var paymentTimeout = builder.Configuration.GetValue("Orders:PaymentTimeout") ?? PaymentDeadline.Default; builder.Services.AddSingleton(new PaymentDeadline(paymentTimeout)); @@ -30,7 +30,7 @@ // The cart inactivity window (slice 3.4): how long a cart may sit without activity before the // scheduled CartActivityTimeout abandons it. Config-driven like the payment deadline above; one // value feeds the AddToCart schedule, the abandonment handler's fire-and-check decision, and the -// CartsAwaitingActivity projection's visible deadline. +// awaiting-activity endpoint's visible deadline (computed on read — the projection is stateless). var cartActivityTimeout = builder.Configuration.GetValue("Orders:CartActivityTimeout") ?? CartActivityDeadline.Default; builder.Services.AddSingleton(new CartActivityDeadline(cartActivityTimeout)); @@ -71,13 +71,14 @@ opts.Projections.Snapshot(SnapshotLifecycle.Inline); // The Bruun todo-list (slice 4.7): a second inline projection over the same Order stream. - // Instance-registered (not generic) because the projection carries the configured payment - // timeout — the row's visible deadline must match the schedule PlaceOrder actually sets. - opts.Projections.Add(new OrdersAwaitingPaymentProjection(paymentTimeout), ProjectionLifecycle.Inline); - - // The cart-side Bruun todo-list (slice 3.4): the same shape over the Cart stream — - // instance-registered with the configured inactivity window. - opts.Projections.Add(new CartsAwaitingActivityProjection(cartActivityTimeout), ProjectionLifecycle.Inline); + // STATELESS + generic-registered: it records the placement timestamp only; the awaiting-payment + // endpoint adds the configured timeout on read. (Marten 9.x re-materializes instance-registered + // projections, dropping constructor-injected state — chore/004; see OrdersAwaitingPayment remarks.) + opts.Projections.Add(ProjectionLifecycle.Inline); + + // The cart-side Bruun todo-list (slice 3.4): the same stateless shape over the Cart stream — + // records the latest activity timestamp; the awaiting-activity endpoint adds the window on read. + opts.Projections.Add(ProjectionLifecycle.Inline); // The round-one ASYNC projection teaser (ADR 008, slice 3.4): registered with the async // lifecycle but NO AddAsyncDaemon anywhere — the daily abandonment report stays empty diff --git a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs index c3be13d..f625b8f 100644 --- a/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs +++ b/src/CritterMart.Orders/Shopping/CartsAwaitingActivity.cs @@ -5,46 +5,52 @@ namespace CritterMart.Orders.Shopping; // The cart-side Bruun todo-list (Workshop 001 § 5 / § 7: CartsAwaitingActivity*): one row per // open cart, readable at GET /carts/awaiting-activity. The row is created when CartCreated folds, -// its deadline advances as activity events fold, and it is DELETED when either terminal event -// folds — a Marten conditional delete. The abandonment handler does NOT read this view (the Cart -// stream is the single source of truth for the abandonment decision; design.md Decision 2) — +// its activity timestamp advances as activity events fold, and it is DELETED when either terminal +// event folds — a Marten conditional delete. The abandonment handler does NOT read this view (the +// Cart stream is the single source of truth for the abandonment decision; design.md Decision 2) — // this is the observable face of the automation, mirroring Order/OrdersAwaitingPayment. +// +// The view stores LastActivityAt (a fact: the most recent activity event's append time); the visible +// Deadline is computed at READ time as LastActivityAt + the configured inactivity window. The policy +// lives on the read side so the projection stays STATELESS — under Marten 9.x an instance-registered +// inline projection is re-materialized and constructor-injected state (a captured TimeSpan) is lost +// (chore/004, the CW/Marten 9.12 upgrade). Mirrors OrdersAwaitingPayment. public class CartAwaitingActivity { public string Id { get; set; } = string.Empty; // the cartId (stream key) public string CustomerId { get; set; } = string.Empty; - public DateTimeOffset Deadline { get; set; } // when the cart becomes abandonable + public DateTimeOffset LastActivityAt { get; set; } // most recent activity time; Deadline = LastActivityAt + window (on read) } +// The read-shaped row returned by GET /carts/awaiting-activity: the stored LastActivityAt projected +// forward by the configured inactivity window into the visible Deadline. Distinct from the stored view +// so the projection stays a pure fact-recorder and the window is applied once, on read. +public record CartAwaitingActivityRow(string Id, string CustomerId, DateTimeOffset Deadline); + // Inline single-stream projection (ADR 008) over the same Cart stream that CartView folds — two // inline projections plus one async projection (CartAbandonmentReport), all over the same events: -// projection shape and lifecycle are per-projection choices, not per-event ones. The configured -// window is constructor-injected (instance registration in Program.cs) so the row's visible -// deadline matches what the abandonment handler will actually decide. +// projection shape and lifecycle are per-projection choices, not per-event ones. STATELESS by design +// (see the view remarks) — it records the latest activity timestamp; the endpoint adds the window on read. public partial class CartsAwaitingActivityProjection : SingleStreamProjection { - private readonly TimeSpan _activityWindow; - - public CartsAwaitingActivityProjection(TimeSpan activityWindow) => _activityWindow = activityWindow; - // The IEvent wrapper exposes the event's append timestamp (Marten's using-metadata convention): - // the deadline is the activity time plus the configured window. + // store it raw; the deadline is computed on read from the configured inactivity window. public void Apply(IEvent e, CartAwaitingActivity view) { view.CustomerId = e.Data.CustomerId; - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; } - // Every activity event pushes the deadline out — the visible mirror of fire-and-check: the + // Every activity event pushes the activity timestamp out — the visible mirror of fire-and-check: the // pending scheduled message is NOT moved (Wolverine can't); the fired handler re-aims instead. public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; public void Apply(IEvent e, CartAwaitingActivity view) => - view.Deadline = e.Timestamp.Add(_activityWindow); + view.LastActivityAt = e.Timestamp; // Conditional deletes (Marten 9 ShouldDelete method convention): either terminal event — // checkout (slice 4.1) or abandonment (slice 3.4) — removes the row from the todo-list. diff --git a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs index b8aff5e..8ede53d 100644 --- a/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs +++ b/tests/CritterMart.Orders.Tests/CartAbandonmentTests.cs @@ -201,7 +201,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o _.Get.Url("/carts/awaiting-activity"); _.StatusCodeShouldBe(200); }); - var rows = listed.ReadAsJson>()!; + var rows = listed.ReadAsJson>()!; var row = rows.ShouldHaveSingleItem(); row.Id.ShouldBe(cartId); row.CustomerId.ShouldBe("customer-V"); @@ -216,7 +216,7 @@ public async Task the_awaiting_activity_list_shows_open_carts_and_drops_closed_o _.Get.Url("/carts/awaiting-activity"); _.StatusCodeShouldBe(200); }); - afterAbandon.ReadAsJson>()!.ShouldBeEmpty(); + afterAbandon.ReadAsJson>()!.ShouldBeEmpty(); } // ADR 008's teaching beat, proven: the async CartAbandonmentReport is EMPTY until an diff --git a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs index 55c69a0..3543634 100644 --- a/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs +++ b/tests/CritterMart.Orders.Tests/CartsAwaitingActivityProjectionTests.cs @@ -6,49 +6,50 @@ namespace CritterMart.Orders.Tests; // Pure-function unit tests for the CartsAwaitingActivity todo-list fold (slice 3.4) — no database, -// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside -// the other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods -// take IEvent wrappers (Marten's using-metadata convention) so the visible deadline is computed -// from each activity event's append timestamp; the tests construct the wrappers with Event. +// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the +// other projection fold tests. Mirrors OrdersAwaitingPaymentProjectionTests: Apply methods take +// IEvent wrappers (Marten's using-metadata convention) so each activity event's append timestamp is +// recorded; the tests construct the wrappers with Event. The projection is STATELESS — the visible +// deadline (LastActivityAt + window) is applied on read in the endpoint (chore/004). public class CartsAwaitingActivityProjectionTests { private static readonly ProductSnapshot CosmicCritterPlush = new("Cosmic Critter Plush", 24.99m); - private static readonly TimeSpan Window = TimeSpan.FromHours(2); private static readonly DateTimeOffset T0 = new(2026, 6, 2, 12, 0, 0, TimeSpan.Zero); - private readonly CartsAwaitingActivityProjection _projection = new(Window); + private readonly CartsAwaitingActivityProjection _projection = new(); private static Event At(T data, DateTimeOffset timestamp) where T : notnull => new(data) { Timestamp = timestamp }; - // CartCreated creates the row: the cart joins the todo-list with a deadline of creation - // time plus the configured inactivity window. + // CartCreated creates the row: the cart joins the todo-list recording its creation timestamp + // (the read endpoint adds the configured inactivity window to produce the visible deadline). [Fact] - public void cart_created_creates_a_row_with_the_deadline() + public void cart_created_records_the_activity_timestamp() { var view = new CartAwaitingActivity(); _projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view); view.CustomerId.ShouldBe("customer-X"); - view.Deadline.ShouldBe(T0.Add(Window)); + view.LastActivityAt.ShouldBe(T0); } - // Every activity event pushes the deadline out — the visible mirror of fire-and-check. + // Every activity event advances the recorded activity timestamp — the visible mirror of + // fire-and-check (the read endpoint projects it forward by the window). [Fact] - public void cart_activity_advances_the_deadline() + public void cart_activity_advances_the_activity_timestamp() { var view = new CartAwaitingActivity(); _projection.Apply(At(new CartCreated("cart-1", "customer-X"), T0), view); _projection.Apply(At(new CartItemAdded("crit-001", 1, CosmicCritterPlush), T0.AddMinutes(10)), view); - view.Deadline.ShouldBe(T0.AddMinutes(10).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(10)); _projection.Apply(At(new CartItemQuantityChanged("crit-001", 3), T0.AddMinutes(30)), view); - view.Deadline.ShouldBe(T0.AddMinutes(30).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(30)); _projection.Apply(At(new CartItemRemoved("crit-001"), T0.AddMinutes(55)), view); - view.Deadline.ShouldBe(T0.AddMinutes(55).Add(Window)); + view.LastActivityAt.ShouldBe(T0.AddMinutes(55)); } // The conditional delete (Marten ShouldDelete convention): a checked-out cart leaves the list. diff --git a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs index 412a947..d4ff61e 100644 --- a/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs +++ b/tests/CritterMart.Orders.Tests/OrdersAwaitingPaymentProjectionTests.cs @@ -6,21 +6,21 @@ namespace CritterMart.Orders.Tests; // Pure-function unit tests for the OrdersAwaitingPayment todo-list fold (slice 4.7) — no database, -// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside -// the other projection fold tests. The Apply takes an IEvent wrapper (Marten's -// using-metadata convention) so the deadline can be computed from the event's append timestamp; -// the test constructs the wrapper directly with Event. +// no mocks, no container. Untagged, so they run in the CI `Category!=Integration` job alongside the +// other projection fold tests. The Apply takes an IEvent wrapper (Marten's using-metadata +// convention) so it can record the event's append timestamp; the test constructs the wrapper directly +// with Event. The projection is STATELESS — the visible deadline (PlacedAt + timeout) is applied on +// read in the endpoint, not here (chore/004; see OrdersAwaitingPayment remarks). public class OrdersAwaitingPaymentProjectionTests { private static readonly OrderLine Plush = new("crit-001", 2, "Cosmic Critter Plush", 24.99m); - private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10); - private readonly OrdersAwaitingPaymentProjection _projection = new(Timeout); + private readonly OrdersAwaitingPaymentProjection _projection = new(); - // OrderPlaced creates the row: the order joins the todo-list with a deadline of placement - // time plus the configured payment timeout. + // OrderPlaced creates the row: the order joins the todo-list recording its placement timestamp + // (the read endpoint adds the configured timeout to produce the visible deadline). [Fact] - public void order_placed_creates_a_row_with_the_deadline() + public void order_placed_creates_a_row_recording_placed_at() { var view = new OrderAwaitingPayment(); var placedAt = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); @@ -33,7 +33,7 @@ public void order_placed_creates_a_row_with_the_deadline() view.CustomerId.ShouldBe("customer-X"); view.Total.ShouldBe(49.98m); - view.Deadline.ShouldBe(placedAt.Add(Timeout)); + view.PlacedAt.ShouldBe(placedAt); } // The conditional delete (Marten ShouldDelete convention): a confirmed order leaves the list. diff --git a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs index 7e52520..2634752 100644 --- a/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs +++ b/tests/CritterMart.Orders.Tests/PaymentTimeoutTests.cs @@ -200,7 +200,7 @@ await _fixture.Host.Scenario(_ => _.Get.Url("/orders/awaiting-payment"); _.StatusCodeShouldBe(200); }); - var rows = listed.ReadAsJson>()!; + var rows = listed.ReadAsJson>()!; var row = rows.ShouldHaveSingleItem(); row.Id.ShouldBe(orderId); row.Total.ShouldBe(Total); @@ -214,6 +214,6 @@ await _fixture.Host.Scenario(_ => _.Get.Url("/orders/awaiting-payment"); _.StatusCodeShouldBe(200); }); - afterSettle.ReadAsJson>()!.ShouldBeEmpty(); + afterSettle.ReadAsJson>()!.ShouldBeEmpty(); } }