Skip to content

Commit ebe5194

Browse files
committed
feat: Implement optimistic updates for favorite toggling, rating, and book management
- Added LoggerMessage for book deletion and restoration failures. - Removed OptimisticUpdateService as its functionality is now integrated into ReactiveQuery. - Refactored CatalogService to utilize ReactiveQuery for optimistic updates on favorite toggling, rating, and removing ratings. - Implemented soft delete and restore functionality in CatalogService with appropriate logging. - Updated unit tests to cover new behaviors and ensure correct handling of optimistic updates.
1 parent e0eb6ed commit ebe5194

19 files changed

Lines changed: 1248 additions & 1004 deletions

File tree

.github/agents/BackendDeveloper.agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ target: vscode
88
user-invocable: false
99
disable-model-invocation: true
1010
model: GPT-5.3-Codex (copilot)
11-
tools: ['search', 'read', 'edit', 'execute/runInTerminal', 'vscode/memory', 'agent', 'vscode/askQuestions']
11+
tools: [vscode/memory, vscode/askQuestions, execute/runInTerminal, read, agent, edit, search, azure-mcp/search, djextensions.lsp-for-copilot/definition, djextensions.lsp-for-copilot/references, djextensions.lsp-for-copilot/hover, djextensions.lsp-for-copilot/symbols, djextensions.lsp-for-copilot/wsSymbols, djextensions.lsp-for-copilot/callHierarchy, djextensions.lsp-for-copilot/typeHierarchy, djextensions.lsp-for-copilot/typeDef, djextensions.lsp-for-copilot/impl, djextensions.lsp-for-copilot/findSym, djextensions.lsp-for-copilot/declaration, djextensions.lsp-for-copilot/diagnostics, djextensions.lsp-for-copilot/inlayHints]
1212
agents: ['*']
1313
---
1414

.github/agents/CodeReviewer.agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ target: vscode
88
user-invocable: false
99
disable-model-invocation: true
1010
model: GPT-5.4 (copilot)
11-
tools: ['search', 'read', 'vscode/memory', 'agent']
11+
tools: [vscode/memory, read, agent, search, azure-mcp/search, djextensions.lsp-for-copilot/definition, djextensions.lsp-for-copilot/references, djextensions.lsp-for-copilot/hover, djextensions.lsp-for-copilot/symbols, djextensions.lsp-for-copilot/wsSymbols, djextensions.lsp-for-copilot/callHierarchy, djextensions.lsp-for-copilot/typeHierarchy, djextensions.lsp-for-copilot/typeDef, djextensions.lsp-for-copilot/impl, djextensions.lsp-for-copilot/findSym, djextensions.lsp-for-copilot/declaration, djextensions.lsp-for-copilot/diagnostics, djextensions.lsp-for-copilot/inlayHints]
1212
agents: ['*']
1313
---
1414

.github/agents/FrontendDeveloper.agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ target: vscode
88
user-invocable: false
99
disable-model-invocation: true
1010
model: GPT-5.3-Codex (copilot)
11-
tools: ['search', 'read', 'edit', 'execute/runInTerminal', 'vscode/memory', 'agent', 'vscode/askQuestions']
11+
tools: [vscode/memory, vscode/askQuestions, execute/runInTerminal, read, agent, edit, search, browser, azure-mcp/search, djextensions.lsp-for-copilot/definition, djextensions.lsp-for-copilot/references, djextensions.lsp-for-copilot/hover, djextensions.lsp-for-copilot/symbols, djextensions.lsp-for-copilot/wsSymbols, djextensions.lsp-for-copilot/callHierarchy, djextensions.lsp-for-copilot/typeHierarchy, djextensions.lsp-for-copilot/typeDef, djextensions.lsp-for-copilot/impl, djextensions.lsp-for-copilot/findSym, djextensions.lsp-for-copilot/declaration, djextensions.lsp-for-copilot/diagnostics, djextensions.lsp-for-copilot/inlayHints]
1212
agents: ['*']
1313
---
1414

.github/agents/TestEngineer.agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ target: vscode
88
user-invocable: false
99
disable-model-invocation: true
1010
model: GPT-5.3-Codex (copilot)
11-
tools: ['search', 'read', 'edit', 'execute/runInTerminal', 'execute/testFailure', 'vscode/memory', 'agent', 'vscode/askQuestions']
11+
tools: [vscode/memory, vscode/askQuestions, execute/testFailure, execute/runInTerminal, read, agent, edit, search, browser, azure-mcp/search, djextensions.lsp-for-copilot/definition, djextensions.lsp-for-copilot/references, djextensions.lsp-for-copilot/hover, djextensions.lsp-for-copilot/symbols, djextensions.lsp-for-copilot/wsSymbols, djextensions.lsp-for-copilot/callHierarchy, djextensions.lsp-for-copilot/typeHierarchy, djextensions.lsp-for-copilot/typeDef, djextensions.lsp-for-copilot/impl, djextensions.lsp-for-copilot/findSym, djextensions.lsp-for-copilot/declaration, djextensions.lsp-for-copilot/diagnostics, djextensions.lsp-for-copilot/inlayHints]
1212
agents: ['*']
1313
---
1414

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Optimistic Mutations Guide
2+
3+
This guide describes the BookStore frontend pattern for optimistic UI updates using `ReactiveQuery<T>.MutateAsync(...)` and `CatalogService`.
4+
5+
## Why this Pattern Exists
6+
7+
Optimistic UI gives immediate feedback while a mutation is in-flight. Without a reusable pattern, every component must re-implement:
8+
9+
1. snapshot current state
10+
2. apply optimistic state
11+
3. call API
12+
4. rollback on failure
13+
5. handle success/error notifications
14+
15+
BookStore now centralizes this flow so components stay thin and consistent.
16+
17+
## Architecture
18+
19+
The optimistic mutation stack has three layers:
20+
21+
1. **ReactiveQuery** (`src/BookStore.Web/Services/ReactiveQuery.cs`)
22+
- Owns query state (`Data`, loading, errors)
23+
- Provides `MutateAsync` for generic optimistic lifecycle
24+
2. **CatalogService** (`src/BookStore.Web/Services/CatalogService.cs`)
25+
- Encodes domain-specific optimistic transforms (favorite, rating, soft delete, restore)
26+
- Calls Refit clients and displays snackbar feedback
27+
3. **Components** (`.razor` pages)
28+
- Trigger mutations by calling service methods with the active query
29+
- Do not implement rollback logic inline
30+
31+
## Core Primitive: `ReactiveQuery<T>.MutateAsync`
32+
33+
`MutateAsync` implements the reusable optimistic lifecycle:
34+
35+
1. Store a snapshot of current `Data`
36+
2. Apply optimistic transform
37+
3. Execute server mutation
38+
4. On failure, restore snapshot and rethrow
39+
40+
```csharp
41+
await query.MutateAsync(
42+
applyOptimistic: current => current with { IsFavorite = true },
43+
mutation: ct => _booksClient.AddBookToFavoritesAsync(book.Id, cancellationToken: ct),
44+
cancellationToken: cancellationToken);
45+
```
46+
47+
## Service Pattern
48+
49+
`CatalogService` should own mutation behavior and keep component code minimal.
50+
51+
### Detail query (`ReactiveQuery<BookDto?>`)
52+
53+
```csharp
54+
public async Task SoftDeleteBookAsync(BookDto book, ReactiveQuery<BookDto?> query, CancellationToken cancellationToken = default)
55+
{
56+
try
57+
{
58+
await query.MutateAsync(
59+
current => current == null ? null : current with { IsDeleted = true },
60+
ct => _booksClient.SoftDeleteBookAsync(book.Id, book.ETag, ct),
61+
cancellationToken);
62+
63+
_ = _snackbar.Add("Book deleted", Severity.Success);
64+
}
65+
catch (Exception ex)
66+
{
67+
Log.BookDeleteFailed(_logger, book.Id, ex);
68+
_ = _snackbar.Add($"Failed to delete book: {ex.Message}", Severity.Error);
69+
}
70+
}
71+
```
72+
73+
### List query (`ReactiveQuery<PagedListDto<BookDto>>`)
74+
75+
For list updates, mutate only the matching row and return a new list DTO.
76+
77+
```csharp
78+
await query.MutateAsync(
79+
currentList =>
80+
{
81+
var items = currentList.Items.ToList();
82+
var index = items.FindIndex(b => b.Id == book.Id);
83+
if (index != -1)
84+
{
85+
items[index] = items[index] with
86+
{
87+
IsFavorite = !originalState,
88+
LikeCount = originalState ? items[index].LikeCount - 1 : items[index].LikeCount + 1
89+
};
90+
}
91+
92+
return new PagedListDto<BookDto>(items, currentList.PageNumber, currentList.PageSize, currentList.TotalItemCount);
93+
},
94+
mutation: ct => _booksClient.AddBookToFavoritesAsync(book.Id, cancellationToken: ct),
95+
cancellationToken: cancellationToken);
96+
```
97+
98+
## Component Usage
99+
100+
Components should pass the active query object and avoid custom rollback lambdas.
101+
102+
```csharp
103+
if (bookQuery == null) return;
104+
await CatalogService.ToggleFavoriteAsync(book, bookQuery, _cts.Token);
105+
```
106+
107+
## SSE and Eventual Consistency
108+
109+
Book projections are asynchronous. After delete/restore, a direct immediate `LoadAsync()` can read stale projection data.
110+
111+
Preferred behavior:
112+
113+
1. keep optimistic local state from `MutateAsync`
114+
2. let SSE-triggered invalidation refresh in background when projection catches up
115+
116+
Avoid forcing immediate reload after delete/restore unless there is a strong consistency requirement.
117+
118+
## Do / Don't
119+
120+
```text
121+
✅ Use ReactiveQuery.MutateAsync for optimistic lifecycle
122+
✅ Keep mutation transforms in CatalogService (or domain service), not in components
123+
✅ Keep components as orchestration-only for UI actions
124+
✅ Let SSE invalidation reconcile eventual consistency
125+
126+
❌ Reintroduce setOptimistic/setRollback lambdas in .razor files
127+
❌ Reintroduce separate optimistic ghost-state cache for this flow
128+
❌ Immediately force LoadAsync() after async-projection mutations by default
129+
```
130+
131+
## Migration Checklist
132+
133+
When converting older optimistic code:
134+
135+
1. Replace inline `MutateData` + try/catch rollback with `query.MutateAsync`
136+
2. Move transform logic into service methods
137+
3. Update component calls to pass query (`CatalogService.XxxAsync(book, bookQuery, ct)`)
138+
4. Keep snackbar/logging in service catch blocks
139+
5. Validate with:
140+
141+
```bash
142+
dotnet build BookStore.slnx
143+
dotnet test tests/BookStore.Web.Tests/
144+
dotnet format BookStore.slnx --verify-no-changes
145+
```

docs/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
href: guides/database-indexes-guide.md
4343
- name: Caching
4444
href: guides/caching-guide.md
45+
- name: Optimistic Mutations
46+
href: guides/optimistic-mutations-guide.md
4547
- name: ETag
4648
href: guides/etag-guide.md
4749
- name: Performance

src/BookStore.ApiService/Endpoints/BookEndpoints.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(
124124
var isAdmin = context.User.Claims.Any(c =>
125125
(c.Type == System.Security.Claims.ClaimTypes.Role || c.Type == "role") &&
126126
string.Equals(c.Value, "Admin", StringComparison.OrdinalIgnoreCase));
127+
var includeDeleted = isAdmin && request.IncludeDeleted == true;
127128

128129
// Fix binding if Currency is null but present in query (workaround for potential AsParameters binding issue)
129130
if (string.IsNullOrWhiteSpace(request.Currency) && context.Request.Query.TryGetValue("currency", out var currencyQuery))
@@ -135,7 +136,7 @@ static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(
135136
var normalizedSortBy = request.SortBy?.ToLowerInvariant();
136137

137138
// Create cache key including all search parameters
138-
var cacheKey = $"books:search={request.Search}:author={request.AuthorId}:category={request.CategoryId}:publisher={request.PublisherId}:onSale={request.OnSale}:minPrice={request.MinPrice}:maxPrice={request.MaxPrice}:currency={request.Currency}:page={paging.Page}:size={paging.PageSize}:sort={normalizedSortBy}:{normalizedSortOrder}:admin={isAdmin}:tenant={tenantContext.TenantId}";
139+
var cacheKey = $"books:search={request.Search}:author={request.AuthorId}:category={request.CategoryId}:publisher={request.PublisherId}:onSale={request.OnSale}:minPrice={request.MinPrice}:maxPrice={request.MaxPrice}:currency={request.Currency}:includeDeleted={includeDeleted}:page={paging.Page}:size={paging.PageSize}:sort={normalizedSortBy}:{normalizedSortOrder}:tenant={tenantContext.TenantId}";
139140

140141
var response = await cache.GetOrCreateLocalizedAsync(
141142
cacheKey,
@@ -159,7 +160,7 @@ static async Task<Ok<PagedListDto<BookDto>>> SearchBooks(
159160
.Include(authors).On(x => x.AuthorIds)!
160161
.Include(categories).On(x => x.CategoryIds)!;
161162

162-
if (!isAdmin)
163+
if (!includeDeleted)
163164
{
164165
query = query.Where(b => !b.Deleted);
165166
}

src/BookStore.Shared/Models/BookSearchRequest.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ public record BookSearchRequest : OrderedPagedRequest
1010
public decimal? MinPrice { get; init; }
1111
public decimal? MaxPrice { get; init; }
1212
public string? Currency { get; init; }
13+
public bool? IncludeDeleted { get; init; }
1314
}

src/BookStore.Web/AGENTS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
✅ ReactiveQuery<T> for reads ❌ Manual data fetch without invalidation
1212
✅ QueryInvalidationService + SSE ❌ Polling for updates
1313
✅ MudTable Items=@(_query?.Data) ❌ MudTable ServerData= on SSE-driven pages
14-
✅ _query?.MutateData for instant UI ❌ await API then reload (blocks UX)
14+
✅ ReactiveQuery.MutateAsync via CatalogService ❌ setOptimistic/setRollback lambdas in .razor
15+
✅ Keep query data as source of truth ❌ Reintroduce OptimisticUpdateService ghost-state cache
1516
✅ _query.InvalidateAsync() after dialogs ❌ _table.ReloadServerData() after mutations
1617
✅ TenantService for tenant context ❌ Hardcoded tenant or missing headers
1718
```
@@ -20,8 +21,9 @@
2021
- ❌ Calling HttpClient directly → Use injected BookStore.Client interfaces
2122
-`MudTable ServerData=` on SSE-driven admin pages → SSE-triggered reloads bypass `ServerData`; use `ReactiveQuery<IReadOnlyList<T>>` + `Items=@(_query?.Data)` instead
2223
- ❌ Missing SSE invalidation mapping → Update `QueryInvalidationService`; if the entity is stored inside a parent projection (e.g., sales inside `BookSearchProjection`), the parent notification must also yield the child query key
23-
- ❌ Forgetting rollback on failed mutations → Take a snapshot before `MutateData`, restore it in the catch block
24-
- ❌ Using `MutateData` for dialog-based mutations → Only use `MutateData` for inline single-step operations (cancel, delete); after dialogs use `_query.InvalidateAsync()` so the server response is the source of truth
24+
- ❌ Re-implementing optimistic logic in components → Use `CatalogService` methods that call `ReactiveQuery.MutateAsync` and centralize rollback
25+
- ❌ Forcing immediate `LoadAsync()` after delete/restore → async projections can be stale; keep optimistic state and let SSE invalidation refresh
26+
- ❌ Using `MutateData` for dialog-based mutations → for dialog workflows, keep `_query.InvalidateAsync()` so server response remains the source of truth
2527
- ❌ Optimistic removal that leaves stale items after SSE → `MutateData` removes the row immediately; SSE then triggers a silent background `InvalidateAsync()` via `ReactiveQuery`, which replaces the local state; no extra reload needed
2628
- ❌ Business logic in .razor files → Move to Services/ or backing classes
2729
- ❌ Tenant mismatch in UI → Use TenantService and tenant-aware clients

src/BookStore.Web/Components/Pages/BookDetails.razor

Lines changed: 22 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
@using Microsoft.AspNetCore.Components.Authorization
77
@using BookStore.Web.Components.Catalog
88
@using BookStore.Web.Components.Pages.Admin
9+
@using BookStore.Web.Logging
910
@inject IBooksClient BooksClient
1011
@inject IShoppingCartClient CartClient
1112
@inject NavigationManager Navigation
@@ -53,7 +54,14 @@
5354
@* Breadcrumbs *@
5455
<MudBreadcrumbs Items="_breadcrumbItems" Class="mb-4"/>
5556

56-
<MudPaper Elevation="2" Class="pa-6" Style="border-radius: 12px;">
57+
<MudPaper Elevation="2" Class="@($"pa-6 {(book.IsDeleted ? "deleted-book" : "")}")" Style="border-radius: 12px;">
58+
@if (book.IsDeleted)
59+
{
60+
<MudAlert Severity="Severity.Error" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.Delete">
61+
This book has been deleted.
62+
</MudAlert>
63+
}
64+
5765
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
5866
<AuthorizeView Roles="Admin, ADMIN">
5967
<Authorized>
@@ -469,62 +477,23 @@
469477

470478
private async Task ToggleFavorite(BookDto bookToToggle)
471479
{
472-
await CatalogService.ToggleFavoriteAsync(bookToToggle,
473-
setOptimistic: (newState) => bookQuery?.MutateData(current => current == null
474-
? null
475-
: current with
476-
{
477-
IsFavorite = newState,
478-
LikeCount = newState ? current.LikeCount + 1 : current.LikeCount - 1
479-
}),
480-
setRollback: (oldState) => bookQuery?.MutateData(current => current == null
481-
? null
482-
: current with
483-
{
484-
IsFavorite = oldState,
485-
LikeCount = oldState ? current.LikeCount + 1 : current.LikeCount - 1
486-
}),
487-
cancellationToken: _cts.Token);
480+
if (bookQuery == null) return;
481+
482+
await CatalogService.ToggleFavoriteAsync(bookToToggle, bookQuery, _cts.Token);
488483
}
489484

490485
private async Task RateBookAsync(int rating)
491486
{
492-
if (book == null) return;
487+
if (book == null || bookQuery == null) return;
493488

494-
await CatalogService.RateBookAsync(book, rating,
495-
setOptimistic: (newRating) => bookQuery?.MutateData(current => current == null
496-
? null
497-
: current with
498-
{
499-
UserRating = newRating
500-
}),
501-
setRollback: (oldRating) => bookQuery?.MutateData(current => current == null
502-
? null
503-
: current with
504-
{
505-
UserRating = oldRating
506-
}),
507-
cancellationToken: _cts.Token);
489+
await CatalogService.RateBookAsync(book, rating, bookQuery, _cts.Token);
508490
}
509491

510492
private async Task RemoveRatingAsync()
511493
{
512-
if (book == null) return;
494+
if (book == null || bookQuery == null) return;
513495

514-
await CatalogService.RemoveRatingAsync(book,
515-
setOptimistic: () => bookQuery?.MutateData(current => current == null
516-
? null
517-
: current with
518-
{
519-
UserRating = 0
520-
}),
521-
setRollback: (oldRating) => bookQuery?.MutateData(current => current == null
522-
? null
523-
: current with
524-
{
525-
UserRating = oldRating
526-
}),
527-
cancellationToken: _cts.Token);
496+
await CatalogService.RemoveRatingAsync(book, bookQuery, _cts.Token);
528497
}
529498

530499
private async Task DeleteBookAsync()
@@ -539,35 +508,19 @@
539508

540509
if (result != true) return;
541510

542-
try
543-
{
544-
await BooksClient.SoftDeleteBookAsync(currentBook.Id, currentBook.ETag, _cts.Token);
545-
Snackbar.Add("Book deleted", Severity.Success);
546-
if (bookQuery != null) await bookQuery.LoadAsync(cancellationToken: _cts.Token);
547-
}
548-
catch (Exception ex)
549-
{
550-
Logger.LogError(ex, "Failed to delete book {BookId}", currentBook.Id);
551-
Snackbar.Add($"Failed to delete book: {ex.Message}", Severity.Error);
552-
}
511+
if (bookQuery == null) return;
512+
513+
await CatalogService.SoftDeleteBookAsync(currentBook, bookQuery, _cts.Token);
553514
}
554515

555516
private async Task RestoreBookAsync()
556517
{
557518
var currentBook = book;
558519
if (currentBook == null) return;
559520

560-
try
561-
{
562-
await BooksClient.RestoreBookAsync(currentBook.Id, etag: currentBook.ETag, cancellationToken: _cts.Token);
563-
Snackbar.Add("Book restored", Severity.Success);
564-
if (bookQuery != null) await bookQuery.LoadAsync(cancellationToken: _cts.Token);
565-
}
566-
catch (Exception ex)
567-
{
568-
Logger.LogError(ex, "Failed to restore book {BookId}", currentBook.Id);
569-
Snackbar.Add($"Failed to restore book: {ex.Message}", Severity.Error);
570-
}
521+
if (bookQuery == null) return;
522+
523+
await CatalogService.RestoreBookAsync(currentBook, bookQuery, _cts.Token);
571524
}
572525

573526
private async Task EditBookAsync()

0 commit comments

Comments
 (0)