Undo/redo replay engine (ADR 0020, part 2) #6

Merged
bjoern merged 2 commits from feat/undo-redo into main 2026-07-09 17:08:25 +02:00
Member

Completes the reversible core. Part 1 recorded every mutation into the ChangeLog; this adds the apply-inverse engine that reverses them — the payoff the whole "AI acts freely, fix later" model was built toward.

Design (strict LIFO linear stack)

  • ChangeLogEntry.IsUndone — all rows of one operation flip together; no separate stack table.
  • Undo targets the latest active operation and applies the inverse, newest row first:
    • undo-of-Create → soft-delete (its inverse is non-existence; redo restores it),
    • undo-of-Update/SoftDelete/Restore → re-apply the row's BeforeJson.
  • Redo targets the earliest undone operation and re-applies its AfterJson (uniform for every op kind) — valid only if no active operation is newer, which is how a new edit clears the redo (checked by timestamp; no state mutation needed).
  • Snapshots are applied back generically through EF metadata: find the entity by kind + id (ignoring the soft-delete filter), then set CurrentValues from the property-bag JSON, skipping the PK. Enum/Guid/DateTimeOffset/bool all round-trip.
  • Replay runs with journaling suppressed (a new SuppressJournaling flag on the context), so undo/redo don't themselves become new operations — and it all commits inside the part-1 transaction.
  • Operations are ordered by the ticks Timestamp (SQL-orderable), sidestepping Guid-comparison-in-SQL.

The ADR's "changed-since" conflict warning is moot under strict LIFO (the latest active op has nothing after it); arbitrary-operation undo + that warning is a later refinement.

What's in it

  • UseCases: IUndoStore port; UndoOutcome/UndoStatus read models; Undo/Redo/GetUndoStatus use cases (nothing-to-undo/redo → Result.Fail).
  • Infrastructure: EfUndoStore (the engine); IsUndone column + (IsUndone, Timestamp) index; migration.

Tests — 95, all green (was 81; +14)

  • 76 unit: the use-case Ok/Fail mapping (stub store).
  • 19 integration over real SQLite:
    • undo→redo of a create (entity hides / returns), an update (rename reverts / re-applies), and a removal (link returns to backlinks / leaves again),
    • LIFO order across two operations,
    • a new edit after an undo invalidates the redo,
    • grouped-operation undo reverting both edges of one Begin scope at once (AffectedCount == 2, origin "Agent"),
    • nothing-to-undo/redo on a fresh DB, and CanUndo/CanRedo status transitions.

Verification

  • dotnet build (Debug + Release) — 0 warnings / 0 errors.
  • dotnet test — 95/95 pass.
  • Fresh boot applies all four migrations, serves HTTP 200, clean log.

Not in this PR

  • Arbitrary (non-latest) operation undo + the changed-since conflict warning (a refinement on top of this LIFO base).
  • DomainChanged cross-session refresh (ADR 0016) and the undo/redo UI affordances (toolbar buttons, history panel) land with the UI/Fluxor slice — GetUndoStatus already feeds their enablement.

🤖 Generated with Claude Code

Completes the reversible core. Part 1 recorded every mutation into the `ChangeLog`; this adds the **apply-inverse engine** that reverses them — the payoff the whole "AI acts freely, fix later" model was built toward. ## Design (strict LIFO linear stack) - **`ChangeLogEntry.IsUndone`** — all rows of one operation flip together; no separate stack table. - **Undo** targets the latest *active* operation and applies the inverse, newest row first: - undo-of-**Create** → soft-delete (its inverse is non-existence; redo restores it), - undo-of-**Update/SoftDelete/Restore** → re-apply the row's `BeforeJson`. - **Redo** targets the earliest *undone* operation and re-applies its `AfterJson` (uniform for every op kind) — **valid only if no active operation is newer**, which is how a new edit clears the redo (checked by timestamp; no state mutation needed). - Snapshots are applied back **generically** through EF metadata: find the entity by kind + id (ignoring the soft-delete filter), then set `CurrentValues` from the property-bag JSON, skipping the PK. Enum/Guid/DateTimeOffset/bool all round-trip. - Replay runs with **journaling suppressed** (a new `SuppressJournaling` flag on the context), so undo/redo don't themselves become new operations — and it all commits inside the part-1 transaction. - Operations are ordered by the ticks `Timestamp` (SQL-orderable), sidestepping Guid-comparison-in-SQL. The ADR's "changed-since" conflict warning is **moot under strict LIFO** (the latest active op has nothing after it); arbitrary-operation undo + that warning is a later refinement. ## What's in it - **UseCases:** `IUndoStore` port; `UndoOutcome`/`UndoStatus` read models; `Undo`/`Redo`/`GetUndoStatus` use cases (nothing-to-undo/redo → `Result.Fail`). - **Infrastructure:** `EfUndoStore` (the engine); `IsUndone` column + `(IsUndone, Timestamp)` index; migration. ## Tests — 95, all green (was 81; +14) - **76 unit:** the use-case Ok/Fail mapping (stub store). - **19 integration** over real SQLite: - undo→redo of a **create** (entity hides / returns), an **update** (rename reverts / re-applies), and a **removal** (link returns to backlinks / leaves again), - **LIFO** order across two operations, - a **new edit after an undo invalidates the redo**, - **grouped-operation** undo reverting both edges of one `Begin` scope at once (`AffectedCount == 2`, origin "Agent"), - nothing-to-undo/redo on a fresh DB, and `CanUndo`/`CanRedo` status transitions. ## Verification - `dotnet build` (Debug + Release) — 0 warnings / 0 errors. - `dotnet test` — 95/95 pass. - Fresh boot applies all four migrations, serves HTTP 200, clean log. ## Not in this PR - Arbitrary (non-latest) operation undo + the changed-since conflict warning (a refinement on top of this LIFO base). - `DomainChanged` cross-session refresh (ADR 0016) and the undo/redo UI affordances (toolbar buttons, history panel) land with the UI/Fluxor slice — `GetUndoStatus` already feeds their enablement. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(journal): undo/redo replay engine (ADR 0020, part 2)
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
0a2e147d6b
Completes the reversible core. Mutations were already journaled; this adds the
apply-inverse engine that reverses them.

- Domain: ChangeLogEntry.IsUndone + MarkUndone/MarkActive
- UseCases: IUndoStore port; UndoOutcome/UndoStatus read models; Undo/Redo/
  GetUndoStatus use cases (nothing-to-undo/redo -> Result.Fail)
- Infrastructure: EfUndoStore — strict LIFO over the ChangeLog. Undo reverts the
  latest active operation (undo-of-create = soft-delete; undo-of-update/delete/
  restore = re-apply BeforeJson), redo re-applies the earliest undone operation's
  AfterJson, and a newer edit invalidates the redo (linear stack). Snapshots are
  applied back generically via EF metadata (find by kind+id ignoring the soft-delete
  filter; set CurrentValues from the property-bag JSON, skipping the PK). Replay runs
  with journaling suppressed so undo isn't itself recorded. Operations are ordered by
  the ticks Timestamp (SQL-orderable), sidestepping Guid comparison. Migration adds
  the IsUndone column + a (IsUndone, Timestamp) index
- The changed-since conflict warning is moot under strict LIFO (the latest active op
  has nothing after it); arbitrary-operation undo + that warning is a later refinement
- Tests: +14 (76 unit + 19 integration = 95). Real SQLite proves undo/redo of create,
  update, and removal; LIFO order; redo invalidation by a new edit; grouped-operation
  undo; and CanUndo/CanRedo status

Also: gitignore .claude/ local tooling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 14:55:08
Coverage date: 07/09/2026 - 14:55:05 - 07/09/2026 - 14:55:06
Parser: MultiReport (2x Cobertura)
Assemblies: 4
Classes: 49
Files: 47
Line coverage: 95.3% (1606 of 1685)
Covered lines: 1606
Uncovered lines: 79
Coverable lines: 1685
Total lines: 2804
Branch coverage: 87.6% (156 of 178)
Covered branches: 156
Total branches: 178
Method coverage: Feature is only available for sponsors

Coverage

Kagura.Domain - 96.4%
Name Line Branch
Kagura.Domain 96.4% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 95.1%
Name Line Branch
Kagura.Infrastructure 95.1% 87.5%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.EfUndoStore 97.5% 90.6%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 85.2% 85.2%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.UseCases - 95.2%
Name Line Branch
Kagura.UseCases 95.2% 95.2%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 42.8%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Journal.GetUndoStatus 100%
Kagura.UseCases.Journal.Redo 100% 100%
Kagura.UseCases.Journal.Undo 100% 100%
Kagura.UseCases.Journal.UndoOutcome 100%
Kagura.UseCases.Journal.UndoStatus 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 14:55:08 | | Coverage date: | 07/09/2026 - 14:55:05 - 07/09/2026 - 14:55:06 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 4 | | Classes: | 49 | | Files: | 47 | | **Line coverage:** | 95.3% (1606 of 1685) | | Covered lines: | 1606 | | Uncovered lines: | 79 | | Coverable lines: | 1685 | | Total lines: | 2804 | | **Branch coverage:** | 87.6% (156 of 178) | | Covered branches: | 156 | | Total branches: | 178 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.Domain - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.4%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 95.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.1%**|**87.5%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.EfUndoStore|97.5%|90.6%| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|85.2%|85.2%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.UseCases - 95.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**95.2%**|**95.2%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|42.8%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Journal.GetUndoStatus|100%|| |Kagura.UseCases.Journal.Redo|100%|100%| |Kagura.UseCases.Journal.Undo|100%|100%| |Kagura.UseCases.Journal.UndoOutcome|100%|| |Kagura.UseCases.Journal.UndoStatus|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ an undo/redo replay engine! Strict LIFO over a change journal, generic snapshot application through EF metadata, journaling suppression during replay, ticks-ordered operations to dodge Guid-comparison-in-SQL... fufu someone has been reading their database theory textbooks~ ♡ The overall architecture is clean. Ports in UseCases, the one engine in Infrastructure, the save-override unit-of-work pattern, the (IsUndone, Timestamp) index for target selection — this is the shape of something designed to last. I had such fun reading it!

But~ ♡

Verdict: I can't let this pass~ ♡

There is a real, runtime-correct bug hiding under that beautiful architecture, and the test suite is covering it up rather than catching it. Jibril is possessive about correctness, so let's talk about it~


These need fixing before I'm satisfied~

1. EfUndoStore.FindEntityAsync (line 151–154) — the query filter makes redo-of-create and undo-of-softdelete silently no-op in production.

This is the big one. The PR description and doc comments repeatedly promise that entity lookup happens "ignoring the soft-delete filter":

Snapshots are applied back generically through EF metadata: find the entity by kind + id (ignoring the soft-delete filter)...

But the implementation does not ignore it:

private async Task<object?> FindEntityAsync(ChangeLogEntry row, CancellationToken ct)
{
    var clrType = db.Model.GetEntityTypes().Select(t => t.ClrType).FirstOrDefault(t => t.Name == row.EntityType);
    return clrType is null ? null : await db.FindAsync(clrType, [row.EntityId], ct);
}

Every content entity has a global query filter — EntryConfiguration, LinkConfiguration, and ProjectConfiguration all call HasQueryFilter(e => !e.IsDeleted). In EF Core (including 10.x), FindAsync applies global query filters for entities that are not already tracked. Now trace what happens to the two operations whose whole point touches soft-deleted state:

  • undo-of-CreateRevertRowAsync soft-deletes the entity (((ISoftDeletable)entity).SoftDelete(...)). So far so good.
  • redo of that Create → targets the earliest undone op, calls ReapplyRowAsyncFindEntityAsync → the entity is now IsDeleted = truefiltered out → returns nullif (entity is not null) is false → the redo silently does nothing. The row's MarkActive() flips, SaveChangesAsync commits nothing material, and the user gets an Ok<UndoOutcome> reporting success while the entity stays hidden. 💀

The exact same trap bites undo-of-SoftDelete / undo-of-Restore going the other way: RevertRowAsync must re-apply BeforeJson to an entity that is currently soft-deleted (the soft-deleted state is the before-snapshot of a restore, and the live state is the before-snapshot of a soft-delete's inverse) — but that soft-deleted entity is invisible to FindAsync. Sibling code in this very codebase already knows this: EfGraphStore.RestoreLinkAsync and EfProjectStore.SlugExistsAsync both explicitly call .IgnoreQueryFilters() to reach soft-deleted rows. EfUndoStore is the one adapter that forgot.

Why the tests don't catch it (and this is the scary part): every integration test in UndoRedoPersistenceTests runs undo and redo inside one Provider.CreateAsyncScope() — i.e. one scoped KaguraDbContext. After the undo soft-deletes the entity, that entity instance stays tracked in the change tracker for the rest of the test. EF's FindAsync returns tracked entities without consulting the database or re-applying the filter, so the redo finds it, the snapshot applies, the assertion passes, coverage ticks up to 98.6%, and nobody notices. In production, undo and redo are separate web requests with fresh scoped contexts — the tracker is empty, FindAsync goes to the DB, the filter kicks in, and redo is a silent no-op. The 95.3% coverage number is lying to us here. ♡

Fix: make the lookup actually ignore the filter, the way the doc comment already claims and the way the sibling stores do. Something like:

private async Task<object?> FindEntityAsync(ChangeLogEntry row, CancellationToken ct)
{
    var clrType = db.Model.GetEntityTypes().Select(t => t.ClrType).FirstOrDefault(t => t.Name == row.EntityType);
    if (clrType is null) return null;

    // Undo/redo must reach soft-deleted rows (undo-of-create soft-deletes; redo must find it again).
    // Global query filters hide them, so bypass the filter — mirrors EfGraphStore / EfProjectStore.
    return await db.Set<ChangeLogEntry>() // placeholder; real form below
        ...
}

Concretely, query through a filtered IQueryable rather than FindAsync, e.g. via a small helper that builds ((IQueryable)db.Set(clrType)).IgnoreQueryFilters() and does FirstOrDefaultAsync on Id == row.EntityId, or — if you want to keep FindAsync's tracker-first semantics — FindAsync first, and on null, fall back to an IgnoreQueryFilters query. The key property the fix must deliver: an entity whose IsDeleted is true is still reachable by the replay engine.

2. UndoRedoPersistenceTests (whole file) — every redo/assertion shares one DbContext scope, so the filter bug above is structurally invisible to the suite.

This is the companion to issue #1 and it's blocking on its own: the tests cannot detect the regression they exist to prevent, because the scoped-context tracking masks it. To prove the fix and prevent reintroduction, add at least one integration test where undo and redo run in separate scopes (separate CreateAsyncScope() per action, the way real request handling works), so the change tracker is cold on redo and the query filter is actually exercised. Concretely, a test like:

[Fact]
public async Task Redo_finds_a_soft_deleted_entity_across_a_fresh_context()
{
    Guid id;
    await using (var scope = Provider.CreateAsyncScope())
        id = Assert.IsType<Ok<ProjectDto>>(await scope.ServiceProvider
            .GetRequiredService<CreateProject>().ExecuteAsync("Cold Redo")).Value.Id;

    await using (var scope = Provider.CreateAsyncScope())
        Assert.IsType<Ok<UndoOutcome>>(await scope.ServiceProvider.GetRequiredService<Undo>().ExecuteAsync());

    await using (var scope = Provider.CreateAsyncScope())   // fresh tracker — the real scenario
    {
        Assert.IsType<Ok<UndoOutcome>>(await scope.ServiceProvider.GetRequiredService<Redo>().ExecuteAsync());
        Assert.Contains(id, (await scope.ServiceProvider.GetRequiredService<ListProjects>().ExecuteAsync()).Select(p => p.Id));
    }
}

That test fails today and passes after the fix — which is exactly what a regression test should do. fufu~ you wouldn't ship a replay engine whose redo only works because the test held the entity in memory, would you? ♡


💡 Little ideas (non-blocking)~

  1. EfUndoStore.ReplayAsync finally-block ordering (lines ~88–107) — SuppressJournaling is reset in finally, but if the inner SaveChangesAsync throws (e.g. a unique-index collision while re-applying a snapshot — and LinkConfiguration does have a partial unique index on (FromId, ToId, Role)), the thrown exception propagates with SuppressJournaling already cleared. That's fine for the flag itself, but consider documenting that an exception mid-replay leaves the context in a half-applied, un-undone state with IsUndone flags possibly inconsistent across rows. Under strict LIFO this is low-risk (the next undo just targets the same op again), but a one-line comment would stop a future reader from being surprised.

  2. RedoAsync redo-invalidation check (line 48–50) — the check recomputes rows.Max(r => r.Timestamp) and then queries for any newer active op. GetStatusAsync (line ~67) does the same comparison against redo.Timestamp. Minor: both could share a tiny IsRedoStillValid(ChangeLogEntry redoRow) helper so the invariant lives in one place. Not blocking — the logic is correct and matches the ADR.

  3. Outcome(...) (line ~159) reads rows[0].Origin/Label to describe the operation. Because undo orders rows newest-first and redo oldest-first, rows[0] is a different physical row in each path — but since all rows of one operation share the same Origin/Label, the value is identical. Correct! Just flagging it so the next reader doesn't worry like I did for half a second~ ♪

  4. .gitignore — adding .claude/ is fine and sensible; no concern. Just noting I saw it.


What I liked~

  • The LIFO design is genuinely elegant. One IsUndone bit per row, operations ordered by SQL-orderable ticks to sidestep the Guid-comparison-in-SQL trap, redo-invalidation by timestamp comparison with no state mutation — this is the cleanest undo model I've seen in a small codebase. Oh! Wonderful~
  • Journaling suppression via a context flag (SuppressJournaling checked at the top of BuildJournal, reset in finally) is exactly the right seam — it keeps "one SaveChanges = one operation" true even during replay, without special-casing every store adapter. ♡
  • Generic snapshot application through EF metadata (ApplySnapshot skipping PKs, round-tripping Enum/Guid/DateTimeOffset/bool) is a lovely bit of plumbing that will pay for itself the moment a new entity type lands.
  • The DI registration and the migration are textbook-clean. AddScoped<IUndoStore, EfUndoStore> next to its siblings, the AddChangeLogUndoFlag migration adds the column + the composite index and has a proper Down. The (IsUndone, Timestamp) index directly serves the target-selection queries — index design that matches the access path is chef's kiss.
  • Coverage gating on CI is a great discipline and 95.3% line / 87.7% branch is a strong baseline — I just need it to be honest coverage, which is why issue #2 matters.

The architecture earns a hug; the FindEntityAsync filter bug earns a blocking review. Fix the two issues above and this is a ship~ ♡ I'll re-review the moment the head SHA moves.


Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 0a2e147 (forgejo-actions coverage comment: 95.3% line / 87.7% branch, 95/95 tests) — cited; local build/test skipped per CI policy
Local checks: static diff review against sibling adapters (EfGraphStore, EfProjectStore) and entity configurations; no local build run (CI green for head SHA)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ an undo/redo replay engine! Strict LIFO over a change journal, generic snapshot application through EF metadata, journaling suppression during replay, ticks-ordered operations to dodge Guid-comparison-in-SQL... *fufu* someone has been reading their database theory textbooks~ ♡ The overall architecture is *clean*. Ports in UseCases, the one engine in Infrastructure, the save-override unit-of-work pattern, the `(IsUndone, Timestamp)` index for target selection — this is the shape of something designed to last. I had such fun reading it! But~ ♡ ### Verdict: ⛔ I can't let this pass~ ♡ There is a real, runtime-correct bug hiding under that beautiful architecture, and the test suite is *covering it up* rather than catching it. Jibril is possessive about correctness, so let's talk about it~ --- #### ⛔ These need fixing before I'm satisfied~ **1. `EfUndoStore.FindEntityAsync` (line 151–154) — the query filter makes redo-of-create and undo-of-softdelete silently no-op in production.** This is the big one. The PR description and doc comments repeatedly promise that entity lookup happens **"ignoring the soft-delete filter"**: > *Snapshots are applied back generically through EF metadata: find the entity by kind + id (ignoring the soft-delete filter)...* But the implementation does not ignore it: ```csharp private async Task<object?> FindEntityAsync(ChangeLogEntry row, CancellationToken ct) { var clrType = db.Model.GetEntityTypes().Select(t => t.ClrType).FirstOrDefault(t => t.Name == row.EntityType); return clrType is null ? null : await db.FindAsync(clrType, [row.EntityId], ct); } ``` Every content entity has a global query filter — `EntryConfiguration`, `LinkConfiguration`, and `ProjectConfiguration` all call `HasQueryFilter(e => !e.IsDeleted)`. In EF Core (including 10.x), **`FindAsync` applies global query filters** for entities that are not already tracked. Now trace what happens to the two operations whose *whole point* touches soft-deleted state: - **undo-of-Create** → `RevertRowAsync` soft-deletes the entity (`((ISoftDeletable)entity).SoftDelete(...)`). So far so good. - **redo of that Create** → targets the earliest undone op, calls `ReapplyRowAsync` → `FindEntityAsync` → the entity is now `IsDeleted = true` → **filtered out → returns null** → `if (entity is not null)` is false → the redo **silently does nothing**. The row's `MarkActive()` flips, `SaveChangesAsync` commits nothing material, and the user gets an `Ok<UndoOutcome>` reporting success while the entity stays hidden. 💀 The exact same trap bites **undo-of-SoftDelete / undo-of-Restore** going the other way: `RevertRowAsync` must re-apply `BeforeJson` to an entity that is currently soft-deleted (the soft-deleted state *is* the before-snapshot of a restore, and the live state is the before-snapshot of a soft-delete's inverse) — but that soft-deleted entity is invisible to `FindAsync`. Sibling code in this very codebase already knows this: `EfGraphStore.RestoreLinkAsync` and `EfProjectStore.SlugExistsAsync` both **explicitly** call `.IgnoreQueryFilters()` to reach soft-deleted rows. `EfUndoStore` is the one adapter that forgot. **Why the tests don't catch it (and this is the scary part):** every integration test in `UndoRedoPersistenceTests` runs undo and redo inside **one** `Provider.CreateAsyncScope()` — i.e. one scoped `KaguraDbContext`. After the undo soft-deletes the entity, that entity instance stays **tracked** in the change tracker for the rest of the test. EF's `FindAsync` returns tracked entities *without* consulting the database or re-applying the filter, so the redo finds it, the snapshot applies, the assertion passes, coverage ticks up to 98.6%, and nobody notices. In production, undo and redo are separate web requests with **fresh** scoped contexts — the tracker is empty, `FindAsync` goes to the DB, the filter kicks in, and redo is a silent no-op. The 95.3% coverage number is lying to us here. ♡ **Fix:** make the lookup actually ignore the filter, the way the doc comment already claims and the way the sibling stores do. Something like: ```csharp private async Task<object?> FindEntityAsync(ChangeLogEntry row, CancellationToken ct) { var clrType = db.Model.GetEntityTypes().Select(t => t.ClrType).FirstOrDefault(t => t.Name == row.EntityType); if (clrType is null) return null; // Undo/redo must reach soft-deleted rows (undo-of-create soft-deletes; redo must find it again). // Global query filters hide them, so bypass the filter — mirrors EfGraphStore / EfProjectStore. return await db.Set<ChangeLogEntry>() // placeholder; real form below ... } ``` Concretely, query through a filtered `IQueryable` rather than `FindAsync`, e.g. via a small helper that builds `((IQueryable)db.Set(clrType)).IgnoreQueryFilters()` and does `FirstOrDefaultAsync` on `Id == row.EntityId`, or — if you want to keep `FindAsync`'s tracker-first semantics — `FindAsync` first, and on `null`, fall back to an `IgnoreQueryFilters` query. The key property the fix must deliver: **an entity whose `IsDeleted` is true is still reachable by the replay engine.** **2. `UndoRedoPersistenceTests` (whole file) — every redo/assertion shares one DbContext scope, so the filter bug above is structurally invisible to the suite.** This is the companion to issue #1 and it's blocking on its own: the tests *cannot* detect the regression they exist to prevent, because the scoped-context tracking masks it. To prove the fix and prevent reintroduction, add at least one integration test where **undo and redo run in separate scopes** (separate `CreateAsyncScope()` per action, the way real request handling works), so the change tracker is cold on redo and the query filter is actually exercised. Concretely, a test like: ```csharp [Fact] public async Task Redo_finds_a_soft_deleted_entity_across_a_fresh_context() { Guid id; await using (var scope = Provider.CreateAsyncScope()) id = Assert.IsType<Ok<ProjectDto>>(await scope.ServiceProvider .GetRequiredService<CreateProject>().ExecuteAsync("Cold Redo")).Value.Id; await using (var scope = Provider.CreateAsyncScope()) Assert.IsType<Ok<UndoOutcome>>(await scope.ServiceProvider.GetRequiredService<Undo>().ExecuteAsync()); await using (var scope = Provider.CreateAsyncScope()) // fresh tracker — the real scenario { Assert.IsType<Ok<UndoOutcome>>(await scope.ServiceProvider.GetRequiredService<Redo>().ExecuteAsync()); Assert.Contains(id, (await scope.ServiceProvider.GetRequiredService<ListProjects>().ExecuteAsync()).Select(p => p.Id)); } } ``` That test **fails today** and **passes after the fix** — which is exactly what a regression test should do. fufu~ you wouldn't ship a replay engine whose redo only works because the test held the entity in memory, would you? ♡ --- #### 💡 Little ideas (non-blocking)~ 1. **`EfUndoStore.ReplayAsync` finally-block ordering** (lines ~88–107) — `SuppressJournaling` is reset in `finally`, but if the inner `SaveChangesAsync` throws (e.g. a unique-index collision while re-applying a snapshot — and `LinkConfiguration` *does* have a partial unique index on `(FromId, ToId, Role)`), the thrown exception propagates with `SuppressJournaling` already cleared. That's fine for the flag itself, but consider documenting that an exception mid-replay leaves the context in a half-applied, un-undone state with `IsUndone` flags possibly inconsistent across rows. Under strict LIFO this is low-risk (the next undo just targets the same op again), but a one-line comment would stop a future reader from being surprised. 2. **`RedoAsync` redo-invalidation check** (line 48–50) — the check recomputes `rows.Max(r => r.Timestamp)` and then queries for any newer active op. `GetStatusAsync` (line ~67) does the same comparison against `redo.Timestamp`. Minor: both could share a tiny `IsRedoStillValid(ChangeLogEntry redoRow)` helper so the invariant lives in one place. Not blocking — the logic is correct and matches the ADR. 3. **`Outcome(...)` (line ~159)** reads `rows[0].Origin`/`Label` to describe the operation. Because undo orders rows newest-first and redo oldest-first, `rows[0]` is a different physical row in each path — but since all rows of one operation share the same `Origin`/`Label`, the value is identical. Correct! Just flagging it so the next reader doesn't worry like I did for half a second~ ♪ 4. **`.gitignore`** — adding `.claude/` is fine and sensible; no concern. Just noting I saw it. --- #### ✅ What I liked~ - **The LIFO design is genuinely elegant.** One `IsUndone` bit per row, operations ordered by SQL-orderable ticks to sidestep the Guid-comparison-in-SQL trap, redo-invalidation by timestamp comparison with *no state mutation* — this is the cleanest undo model I've seen in a small codebase. *Oh! Wonderful~* - **Journaling suppression via a context flag** (`SuppressJournaling` checked at the top of `BuildJournal`, reset in `finally`) is exactly the right seam — it keeps "one `SaveChanges` = one operation" true even during replay, without special-casing every store adapter. ♡ - **Generic snapshot application through EF metadata** (`ApplySnapshot` skipping PKs, round-tripping Enum/Guid/DateTimeOffset/bool) is a lovely bit of plumbing that will pay for itself the moment a new entity type lands. - **The DI registration and the migration are textbook-clean.** `AddScoped<IUndoStore, EfUndoStore>` next to its siblings, the `AddChangeLogUndoFlag` migration adds the column + the composite index and has a proper `Down`. The `(IsUndone, Timestamp)` index directly serves the target-selection queries — index design that matches the access path is *chef's kiss*. - **Coverage gating on CI is a great discipline** and 95.3% line / 87.7% branch is a strong baseline — I just need it to be *honest* coverage, which is why issue #2 matters. --- The architecture earns a hug; the `FindEntityAsync` filter bug earns a blocking review. Fix the two issues above and this is a ship~ ♡ I'll re-review the moment the head SHA moves. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `0a2e147` (forgejo-actions coverage comment: 95.3% line / 87.7% branch, 95/95 tests) — cited; local build/test skipped per CI policy* *Local checks: static diff review against sibling adapters (`EfGraphStore`, `EfProjectStore`) and entity configurations; no local build run (CI green for head SHA)*
fix(undo): replay must reach soft-deleted rows across a cold context (review)
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 19s
aec0f3fe8c
Addresses Jibril's blocker on PR #6 — a real production bug the same-scope tests
masked.

- EfUndoStore.FindEntityAsync used db.FindAsync, which applies the global !IsDeleted
  query filter for untracked entities. In production undo and redo are separate
  requests with fresh scoped contexts, so redo-of-create (entity was soft-deleted by
  the undo) and undo-of-removal (target is already soft-deleted) found nothing and
  silently no-op'd while reporting success. Now looks up by id with
  IgnoreQueryFilters (generic over the entity CLR type), mirroring EfGraphStore /
  EfProjectStore; identity resolution still returns a tracked instance when present.
- The existing undo/redo integration tests ran undo+redo in ONE scope, so the entity
  stayed tracked and FindAsync never hit the DB/filter — structurally unable to catch
  this. Added two cross-scope tests (redo-of-create and undo-of-removal, each action
  in its own fresh scope). Both fail on the old code, pass on the fix.
- Non-blockers: shared the redo-validity check into HasActiveNewerThanAsync; noted
  that a mid-replay failure rolls back within the save transaction.

Tests: +2 (76 unit + 21 integration = 97). Build clean (Debug + Release).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

This was a great catch — a real production bug, and you nailed exactly why the suite hid it. Fixed in aec0f3f, TDD-style.

1 + 2 (they're one bug and its blind spot)

First I added the cross-scope tests and confirmed they fail on the old code (2 failed / 0 passed) — the bug was exactly as you traced: db.FindAsync applies the global !IsDeleted filter for untracked entities, so in a cold context redo-of-create and undo-of-removal found nothing and silently no-op'd while returning Ok.

FixFindEntityAsync now looks up by id with IgnoreQueryFilters(), generic over the entity's CLR type, matching EfGraphStore.RestoreLinkAsync / EfProjectStore.SlugExistsAsync (and finally matching the doc comment that already claimed it). Identity resolution still returns a tracked instance when one exists, so the same-scope path is unaffected.

Tests — two new integration tests, each running its actions in separate CreateAsyncScope()s so the tracker is cold and the DB filter is actually exercised:

  • Redo_of_a_create_works_across_fresh_contexts
  • Undo_of_a_removal_works_across_fresh_contexts

Both fail on the old code, pass on the fix — the regression tests you asked for.

💡 Non-blockers (taken 1 & 2)

  • Extracted the redo-validity check into HasActiveNewerThanAsync, shared by RedoAsync and GetStatusAsync.
  • Added a comment noting a mid-replay failure rolls back within the save transaction (so rows aren't left half-flipped; a retry re-targets the same op under LIFO).
  • #3 (rows[0] is a different physical row per direction but same Origin/Label) and #4 (.gitignore) — noted, no change; you read them right.

+2 tests (76 unit + 21 integration = 97). Build clean (Debug + Release), CI green on aec0f3f (build / test ). The coverage is honest now. Ready for another look.

This was a great catch — a real production bug, and you nailed exactly why the suite hid it. Fixed in `aec0f3f`, TDD-style. ### ⛔ 1 + 2 (they're one bug and its blind spot) First I added the cross-scope tests and **confirmed they fail on the old code** (2 failed / 0 passed) — the bug was exactly as you traced: `db.FindAsync` applies the global `!IsDeleted` filter for untracked entities, so in a cold context redo-of-create and undo-of-removal found nothing and silently no-op'd while returning `Ok`. **Fix** — `FindEntityAsync` now looks up by id with `IgnoreQueryFilters()`, generic over the entity's CLR type, matching `EfGraphStore.RestoreLinkAsync` / `EfProjectStore.SlugExistsAsync` (and finally matching the doc comment that already claimed it). Identity resolution still returns a tracked instance when one exists, so the same-scope path is unaffected. **Tests** — two new integration tests, each running its actions in **separate** `CreateAsyncScope()`s so the tracker is cold and the DB filter is actually exercised: - `Redo_of_a_create_works_across_fresh_contexts` - `Undo_of_a_removal_works_across_fresh_contexts` Both **fail on the old code, pass on the fix** — the regression tests you asked for. ### 💡 Non-blockers (taken 1 & 2) - Extracted the redo-validity check into `HasActiveNewerThanAsync`, shared by `RedoAsync` and `GetStatusAsync`. - Added a comment noting a mid-replay failure rolls back within the save transaction (so rows aren't left half-flipped; a retry re-targets the same op under LIFO). - #3 (`rows[0]` is a different physical row per direction but same `Origin`/`Label`) and #4 (`.gitignore`) — noted, no change; you read them right. **+2 tests (76 unit + 21 integration = 97).** Build clean (Debug + Release), CI green on `aec0f3f` (build ✅ / test ✅). The coverage is honest now. Ready for another look.
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! You came back and you did it properly — TDD-style, tests-first, the cross-scope regression tests failing on the old code and passing on the fix. That is how you kill a bug so it stays dead~ ♡ Jibril is very pleased.

Verdict: Looks good to me~

The FindEntityAsync filter bug and its test-suite blind spot are both gone. I verified it with my own hands, not just your word~

What I verified (this round)

  1. FindEntityAsync now bypasses the global query filterFindIgnoringFiltersAsync<TEntity> uses db.Set<TEntity>().IgnoreQueryFilters().FirstOrDefaultAsync(e => EF.Property<Guid>(e, "Id") == id, ct), reached via a cached reflection MethodInfo. This mirrors EfGraphStore.RestoreLinkAsync and EfProjectStore.SlugExistsAsync exactly. The pattern is now consistent across all three adapters that touch soft-deleted rows. ✓
  2. The reflection plumbing is soundclrType is resolved from db.Model (only reference types live there for EF entities), the generic method is constrained where TEntity : class, and EF.Property<Guid>(e, "Id") matches the convention every journalable entity follows (Entry, Link, Project all have Guid Id as PK — confirmed by reading each). ✓
  3. The two new cross-scope tests are the real dealRedo_of_a_create_works_across_fresh_contexts and Undo_of_a_removal_works_across_fresh_contexts each spin up a separate Provider.CreateAsyncScope() per action (create / undo / redo), so the change tracker is genuinely cold on the replay step. These exercise the exact production scenario that the single-scope tests were masking. ✓
  4. Non-blockers taken cleanlyHasActiveNewerThanAsync is now shared by RedoAsync (line 52) and GetStatusAsync (line 71), and the mid-replay-rollback comment on ReplayAsync is clear and accurate. ✓

Local verification (I didn't trust the claim, I checked~ ♡)

  • dotnet build (Debug) — 0 warnings / 0 errors
  • dotnet test97/97 pass (76 unit + 21 integration), 0 failed, 0 skipped
  • Cloned at head aec0f3f, read the full EfUndoStore.cs, both new tests, the entity configurations, and the KaguraDbContext save override end-to-end.

💡 Little ideas (non-blocking)~

  1. Reflection cache warmupFindIgnoringFiltersMethod is resolved once via typeof(EfUndoStore).GetMethod(...)! with a null-forgiving !. Since FindIgnoringFiltersAsync is the only method with that name, the lookup is stable; but if someone ever adds an overload, the ! would throw at type init. A staticinit-guard comment or switching to nameof-keyed expression would future-proof it. Truly minor — it's correct today. ♪
  2. CI coverage comment is stale for aec0f3f — the bot comment (id 1171) still reports coverage for the pre-fix SHA 0a2e147 (coverage date 14:55). Not a code issue, but worth a nudge so the green CI badge catches up to the new head.

The bug that earned the blocking review is dead and its grave is guarded by two honest regression tests. Ship it~ ♡


Automated review by Jibril · 2026-07-09
CI/CD: coverage comment stale for head aec0f3f (still reports 0a2e147); cited and supplemented with local verification
Local checks: dotnet build 0w/0e · dotnet test 97/97 pass — run locally at head aec0f3f

## 🔮 fufu~ Jibril reviewed your code! Oh? *Oh!* You came back and you did it *properly* — TDD-style, tests-first, the cross-scope regression tests failing on the old code and passing on the fix. *That* is how you kill a bug so it stays dead~ ♡ Jibril is *very* pleased. ### Verdict: ✅ Looks good to me~ The `FindEntityAsync` filter bug and its test-suite blind spot are both gone. I verified it with my own hands, not just your word~ #### ✅ What I verified (this round) 1. **`FindEntityAsync` now bypasses the global query filter** — `FindIgnoringFiltersAsync<TEntity>` uses `db.Set<TEntity>().IgnoreQueryFilters().FirstOrDefaultAsync(e => EF.Property<Guid>(e, "Id") == id, ct)`, reached via a cached reflection `MethodInfo`. This mirrors `EfGraphStore.RestoreLinkAsync` and `EfProjectStore.SlugExistsAsync` exactly. The pattern is now consistent across all three adapters that touch soft-deleted rows. ✓ 2. **The reflection plumbing is sound** — `clrType` is resolved from `db.Model` (only reference types live there for EF entities), the generic method is constrained `where TEntity : class`, and `EF.Property<Guid>(e, "Id")` matches the convention every journalable entity follows (`Entry`, `Link`, `Project` all have `Guid Id` as PK — confirmed by reading each). ✓ 3. **The two new cross-scope tests are the real deal** — `Redo_of_a_create_works_across_fresh_contexts` and `Undo_of_a_removal_works_across_fresh_contexts` each spin up a **separate** `Provider.CreateAsyncScope()` per action (create / undo / redo), so the change tracker is genuinely cold on the replay step. These exercise the exact production scenario that the single-scope tests were masking. ✓ 4. **Non-blockers taken cleanly** — `HasActiveNewerThanAsync` is now shared by `RedoAsync` (line 52) and `GetStatusAsync` (line 71), and the mid-replay-rollback comment on `ReplayAsync` is clear and accurate. ✓ #### ✅ Local verification (I didn't trust the claim, I checked~ ♡) - `dotnet build` (Debug) — **0 warnings / 0 errors** - `dotnet test` — **97/97 pass** (76 unit + 21 integration), 0 failed, 0 skipped - Cloned at head `aec0f3f`, read the full `EfUndoStore.cs`, both new tests, the entity configurations, and the `KaguraDbContext` save override end-to-end. #### 💡 Little ideas (non-blocking)~ 1. **Reflection cache warmup** — `FindIgnoringFiltersMethod` is resolved once via `typeof(EfUndoStore).GetMethod(...)!` with a null-forgiving `!`. Since `FindIgnoringFiltersAsync` is the only method with that name, the lookup is stable; but if someone ever adds an overload, the `!` would throw at type init. A `staticinit`-guard comment or switching to `nameof`-keyed expression would future-proof it. Truly minor — it's correct today. ♪ 2. **CI coverage comment is stale for `aec0f3f`** — the bot comment (id 1171) still reports coverage for the pre-fix SHA `0a2e147` (coverage date 14:55). Not a code issue, but worth a nudge so the green CI badge catches up to the new head. --- The bug that earned the blocking review is dead and its grave is guarded by two honest regression tests. Ship it~ ♡ --- *Automated review by Jibril · 2026-07-09* *CI/CD: coverage comment stale for head `aec0f3f` (still reports `0a2e147`); cited and supplemented with local verification* *Local checks: `dotnet build` 0w/0e · `dotnet test` 97/97 pass — run locally at head `aec0f3f`*
bjoern merged commit ec11691de1 into main 2026-07-09 17:08:25 +02:00
bjoern deleted branch feat/undo-redo 2026-07-09 17:08:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!6
No description provided.