feat: Phase 1 — domain, persistence & manual content management #4

Closed
scarlet wants to merge 3 commits from feat/phase-1-manual-management into main
Member

Implements Phase 1 of docs/PROJECT_PLAN.md: the whole app as a manual doujinshi manager — a real world before any agent runs. No AI, no OpenRouter key required.

What's in here

Domain (ADR 0012, 0013)Project → Chapter → Page → Region plus the five bible tables (glossary, characters, lore, story beats, page summaries). Kagura's entity conventions: sealed classes, Guid v7 ids, timestamps passed in, profile records applied as one write. Hard delete throughout — no soft delete, no journal (ADR 0022).

Persistence (ADR 0005) — single SQLite DB via EF Core, IEntityTypeConfiguration per entity, UtcTicks dates, enums as int, bbox/tags as JSON scalar columns, cascade deletes, and the first migration. Raw page images on disk under projects/<id:N>/<chapterId:N>/raw/, path-canonicalized against traversal.

Use cases (ADR 0003, 0022) — one class per operation, Result<T>, ports in UseCases: project CRUD + the wizard state machine (ADR 0020), zip-aware page import (filename order, dedupe, non-images skipped), chapter/page organize, region editing with never-renumbered p{n}r{m} stable labels, bible CRUD. Agents' tools will drive these same classes later — one write path.

UI (Kagaku.UI) — project list with drafts + modal-gated hard delete; the 3-step creation wizard resuming from stored setup state (step 3 stubbed until the Research & Setup agent, per the plan); pages & chapters (upload incl. zip, reorder, move, kind, delete); the bible's five tables with 700 ms debounced auto-save; the page workspace with Raw · Bbox · Translation views — manual bbox editing via RegionSelector with a labelled overlay, and the pixel-pass views shown as deferred (ADR 0021).

ServerAddUseCases()/AddInfrastructure(), migrate-at-startup, the single authorized page-image endpoint, and the ORIHON_SEED_DEV_DATA hook.

Seed & testsSeedDevData builds the AGENTS.md sample world (contract-tested); 35 tests across the three projects: use-case units over fakes, integration over real SQLite + real files (hard delete leaves nothing behind; hostile zip entry names rejected), bUnit renders.

Verified

  • dotnet build + dotnet test green (35/35) after rebasing onto the settings-page merge (#3).
  • Driven in a browser against the seeded world: gate → list → workspace → page Bbox/region editor → bible auto-save persisting to SQLite.

Ships when (from the plan): you can create a project, upload pages, hand-author regions and the bible, and browse it all end to end — no OpenRouter key required.

🤖 Generated with Claude Code

Implements **Phase 1 of `docs/PROJECT_PLAN.md`**: the whole app as a manual doujinshi manager — a real world before any agent runs. No AI, no OpenRouter key required. ## What's in here **Domain (ADR 0012, 0013)** — `Project → Chapter → Page → Region` plus the five bible tables (glossary, characters, lore, story beats, page summaries). Kagura's entity conventions: sealed classes, Guid v7 ids, timestamps passed in, profile records applied as one write. Hard delete throughout — no soft delete, no journal (ADR 0022). **Persistence (ADR 0005)** — single SQLite DB via EF Core, `IEntityTypeConfiguration` per entity, UtcTicks dates, enums as int, bbox/tags as JSON scalar columns, cascade deletes, and the **first migration**. Raw page images on disk under `projects/<id:N>/<chapterId:N>/raw/`, path-canonicalized against traversal. **Use cases (ADR 0003, 0022)** — one class per operation, `Result<T>`, ports in UseCases: project CRUD + the wizard state machine (ADR 0020), zip-aware page import (filename order, dedupe, non-images skipped), chapter/page organize, region editing with never-renumbered `p{n}r{m}` stable labels, bible CRUD. Agents' tools will drive these same classes later — one write path. **UI (Kagaku.UI)** — project list with drafts + modal-gated hard delete; the 3-step creation wizard resuming from stored setup state (step 3 stubbed until the Research & Setup agent, per the plan); pages & chapters (upload incl. zip, reorder, move, kind, delete); the bible's five tables with 700 ms debounced auto-save; the page workspace with **Raw · Bbox · Translation** views — manual bbox editing via `RegionSelector` with a labelled overlay, and the pixel-pass views shown as deferred (ADR 0021). **Server** — `AddUseCases()`/`AddInfrastructure()`, migrate-at-startup, the single authorized page-image endpoint, and the `ORIHON_SEED_DEV_DATA` hook. **Seed & tests** — `SeedDevData` builds the AGENTS.md sample world (contract-tested); 35 tests across the three projects: use-case units over fakes, integration over real SQLite + real files (hard delete leaves nothing behind; hostile zip entry names rejected), bUnit renders. ## Verified - `dotnet build` + `dotnet test` green (35/35) after rebasing onto the settings-page merge (#3). - Driven in a browser against the seeded world: gate → list → workspace → page Bbox/region editor → bible auto-save persisting to SQLite. **Ships when** (from the plan): *you can create a project, upload pages, hand-author regions and the bible, and browse it all end to end — no OpenRouter key required.* ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The Phase-1 substrate (PROJECT_PLAN): the Project → Chapter → Page → Region model with the
five bible tables (ADR 0012, 0013), EF Core SQLite mappings and the first migration behind
IEntityTypeConfiguration classes (ADR 0005), the on-disk page-image store keyed by ids with
canonicalized paths, and one use case per operation — create/list/delete projects, the wizard
state machine (ADR 0020), zip-aware page import, region editing with never-renumbered stable
labels, and bible CRUD (ADR 0022's single write path). The Server composition root migrates at
startup and serves page images through the single authorized endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Phase-1 surfaces, all Kagaku.UI: the project list with drafts and the modal-gated hard
delete; the three-step creation wizard resuming from the stored setup state (step 3 stubbed
until the setup agent, ADR 0020); the pages-and-chapters workspace with zip upload, reorder,
move, kind, and delete; the bible's five CRUD tables with debounced auto-save and page
summaries surfaced as the blocking gap they are (ADR 0013, 0022); and the page workspace with
the Raw · Bbox · Translation view switcher — RegionSelector-driven manual bbox editing over a
labelled overlay, the region editor auto-saving the whole profile, and the deferred pixel-pass
views shown as not-yet-built (ADR 0012, 0021).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat: sample-world seeder, interactive render mode, and the Phase-1 test suite
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 23s
ebebaa8a50
SeedDevData builds the browser-verification world from AGENTS.md's contract — a ready project,
six pages across the kinds, regions across the taxonomy in RTL order with mixed translation
states, and every bible table — driving real use cases wherever one exists. The adapter pages
gain their @rendermode and App.razor the Kagaku.UI scripts the components interop with
(title/overlay/menu/region), found by driving the seeded app in a browser. Tests land in all
three projects: use-case units over in-memory fakes (zip intake, wizard states, stable-label
rules, last-chapter guard), integration over real SQLite + real files (seed contract, hard
delete leaving nothing behind, path-traversal rejection), and bUnit renders of the project list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/24/2026 - 14:50:35
Coverage date: 07/24/2026 - 14:50:31 - 07/24/2026 - 14:50:33
Parser: MultiReport (3x Cobertura)
Assemblies: 6
Classes: 106
Files: 79
Line coverage: 70.8% (2375 of 3353)
Covered lines: 2375
Uncovered lines: 978
Coverable lines: 3353
Total lines: 6700
Branch coverage: 23.5% (162 of 688)
Covered branches: 162
Total branches: 688
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 4.1%
Name Line Branch
Orihon.BlazorAdapter 4.1% 5%
Orihon.BlazorAdapter.Bible.BiblePage 0% 0%
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.BlazorAdapter.Debounce 0% 0%
Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage 0% 0%
Orihon.BlazorAdapter.Projects.ProjectListPage 53.4% 55.8%
Orihon.BlazorAdapter.Projects.ProjectWizardPage 0% 0%
Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage 0% 0%
Orihon.Domain - 87.5%
Name Line Branch
Orihon.Domain 87.5% 61.7%
Orihon.Domain.Bible.Character 100% 50%
Orihon.Domain.Bible.GlossaryEntry 100% 75%
Orihon.Domain.Bible.LoreEntry 100% 50%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 69.2%
Orihon.Domain.Projects.Project 94.8% 50%
Orihon.Domain.Projects.ProjectProfile 50%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 69.2%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 93.6% 70%
Orihon.Domain.Translation.RegionProfile 50%
Orihon.Infrastructure - 87.3%
Name Line Branch
Orihon.Infrastructure 87.3% 14.1%
Orihon.Infrastructure.Bible.EfBibleStore 31.3% 0%
Orihon.Infrastructure.DependencyInjection 100%
Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper 100%
Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration 100%
Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration 100%
Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain 97.3%
Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot 100%
Orihon.Infrastructure.Persistence.OrihonDbContext 100%
Orihon.Infrastructure.Persistence.OrihonDbContextFactory 0%
Orihon.Infrastructure.Projects.EfProjectStore 90% 50%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 45.4% 37.5%
Orihon.Infrastructure.Translation.EfChapterStore 18.1% 0%
Orihon.Infrastructure.Translation.EfPageStore 44.1% 12.5%
Orihon.Infrastructure.Translation.EfRegionStore 43.7% 16.6%
Orihon.Infrastructure.Translation.Ordering 0% 0%
Orihon.Kernel - 72.7%
Name Line Branch
Orihon.Kernel 72.7% 25%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 66.6% 25%
Orihon.Server - 83.3%
Name Line Branch
Orihon.Server 83.3% 61.7%
Orihon.Server.Components.App 100%
Orihon.Server.Components.Layout.MainLayout 100%
Orihon.Server.Components.Pages.Gate 64.2% 66.6%
Orihon.Server.Security.AccessGate 91.8% 41.6%
Orihon.Server.Security.AccessSecret 100% 50%
Program 81.3% 78.5%
Orihon.UseCases - 77.3%
Name Line Branch
Orihon.UseCases 77.3% 54.9%
Orihon.UseCases.Bible.AddCharacter 77.7% 50%
Orihon.UseCases.Bible.AddGlossaryEntry 77.7% 50%
Orihon.UseCases.Bible.AddLoreEntry 77.7% 50%
Orihon.UseCases.Bible.AddStoryBeat 80% 50%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 0% 0%
Orihon.UseCases.Bible.DeleteGlossaryEntry 0% 0%
Orihon.UseCases.Bible.DeleteLoreEntry 0% 0%
Orihon.UseCases.Bible.DeletePageSummary 0% 0%
Orihon.UseCases.Bible.DeleteStoryBeat 0% 0%
Orihon.UseCases.Bible.GetBible 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 0%
Orihon.UseCases.Bible.SetPageSummary 93.7% 75%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 0% 0%
Orihon.UseCases.Bible.UpdateGlossaryEntry 0% 0%
Orihon.UseCases.Bible.UpdateLoreEntry 0% 0%
Orihon.UseCases.Bible.UpdateStoryBeat 0% 0%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 0% 0%
Orihon.UseCases.Chapters.DeleteChapter 90% 75%
Orihon.UseCases.Chapters.RenameChapter 0% 0%
Orihon.UseCases.Chapters.ReorderChapters 0%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.Diagnostics.SeedDevData 99% 92.8%
Orihon.UseCases.Pages.DeletePage 0% 0%
Orihon.UseCases.Pages.GetPage 90.9% 75%
Orihon.UseCases.Pages.GetProjectWorkspace 95.6% 50%
Orihon.UseCases.Pages.ImportPages 98.4% 92.8%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 84.2% 64.2%
Orihon.UseCases.Pages.PageDetailDto 75%
Orihon.UseCases.Pages.PageDto 95.6%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 75%
Orihon.UseCases.Pages.ReorderPages 0%
Orihon.UseCases.Pages.SetPageMeta 100% 50%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 92.8% 83.3%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 85.7% 50%
Orihon.UseCases.Projects.GetProject 0% 0%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 83.3%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 87.5% 50%
Orihon.UseCases.Regions.CreateRegion 95.8% 50%
Orihon.UseCases.Regions.DeleteRegion 0% 0%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 0%
Orihon.UseCases.Regions.UpdateRegion 100% 50%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 14:50:35 | | Coverage date: | 07/24/2026 - 14:50:31 - 07/24/2026 - 14:50:33 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 6 | | Classes: | 106 | | Files: | 79 | | **Line coverage:** | 70.8% (2375 of 3353) | | Covered lines: | 2375 | | Uncovered lines: | 978 | | Coverable lines: | 3353 | | Total lines: | 6700 | | **Branch coverage:** | 23.5% (162 of 688) | | Covered branches: | 162 | | Total branches: | 688 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 4.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**4.1%**|**5%**| |Orihon.BlazorAdapter.Bible.BiblePage|0%|0%| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| |Orihon.BlazorAdapter.Debounce|0%|0%| |Orihon.BlazorAdapter.PageWorkspace.PageWorkspacePage|0%|0%| |Orihon.BlazorAdapter.Projects.ProjectListPage|53.4%|55.8%| |Orihon.BlazorAdapter.Projects.ProjectWizardPage|0%|0%| |Orihon.BlazorAdapter.Workspace.ProjectWorkspacePage|0%|0%| </details> <details><summary>Orihon.Domain - 87.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**87.5%**|**61.7%**| |Orihon.Domain.Bible.Character|100%|50%| |Orihon.Domain.Bible.GlossaryEntry|100%|75%| |Orihon.Domain.Bible.LoreEntry|100%|50%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|69.2%|| |Orihon.Domain.Projects.Project|94.8%|50%| |Orihon.Domain.Projects.ProjectProfile|50%|| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|69.2%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|93.6%|70%| |Orihon.Domain.Translation.RegionProfile|50%|| </details> <details><summary>Orihon.Infrastructure - 87.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**87.3%**|**14.1%**| |Orihon.Infrastructure.Bible.EfBibleStore|31.3%|0%| |Orihon.Infrastructure.DependencyInjection|100%|| |Orihon.Infrastructure.Persistence.Configurations.ChapterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.CharacterConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.GlossaryEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.JsonColumnMapper|100%|| |Orihon.Infrastructure.Persistence.Configurations.LoreEntryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.PageSummaryConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.RegionConfiguration|100%|| |Orihon.Infrastructure.Persistence.Configurations.StoryBeatConfiguration|100%|| |Orihon.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Orihon.Infrastructure.Persistence.Migrations.InitialTranslationDomain|97.3%|| |Orihon.Infrastructure.Persistence.Migrations.OrihonDbContextModelSnapshot|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContext|100%|| |Orihon.Infrastructure.Persistence.OrihonDbContextFactory|0%|| |Orihon.Infrastructure.Projects.EfProjectStore|90%|50%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|45.4%|37.5%| |Orihon.Infrastructure.Translation.EfChapterStore|18.1%|0%| |Orihon.Infrastructure.Translation.EfPageStore|44.1%|12.5%| |Orihon.Infrastructure.Translation.EfRegionStore|43.7%|16.6%| |Orihon.Infrastructure.Translation.Ordering|0%|0%| </details> <details><summary>Orihon.Kernel - 72.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**72.7%**|**25%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|66.6%|25%| </details> <details><summary>Orihon.Server - 83.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**83.3%**|**61.7%**| |Orihon.Server.Components.App|100%|| |Orihon.Server.Components.Layout.MainLayout|100%|| |Orihon.Server.Components.Pages.Gate|64.2%|66.6%| |Orihon.Server.Security.AccessGate|91.8%|41.6%| |Orihon.Server.Security.AccessSecret|100%|50%| |Program|81.3%|78.5%| </details> <details><summary>Orihon.UseCases - 77.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**77.3%**|**54.9%**| |Orihon.UseCases.Bible.AddCharacter|77.7%|50%| |Orihon.UseCases.Bible.AddGlossaryEntry|77.7%|50%| |Orihon.UseCases.Bible.AddLoreEntry|77.7%|50%| |Orihon.UseCases.Bible.AddStoryBeat|80%|50%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|0%|0%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|0%|0%| |Orihon.UseCases.Bible.DeleteLoreEntry|0%|0%| |Orihon.UseCases.Bible.DeletePageSummary|0%|0%| |Orihon.UseCases.Bible.DeleteStoryBeat|0%|0%| |Orihon.UseCases.Bible.GetBible|100%|| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|0%|| |Orihon.UseCases.Bible.SetPageSummary|93.7%|75%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|0%|0%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|0%|0%| |Orihon.UseCases.Bible.UpdateLoreEntry|0%|0%| |Orihon.UseCases.Bible.UpdateStoryBeat|0%|0%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|0%|0%| |Orihon.UseCases.Chapters.DeleteChapter|90%|75%| |Orihon.UseCases.Chapters.RenameChapter|0%|0%| |Orihon.UseCases.Chapters.ReorderChapters|0%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.Diagnostics.SeedDevData|99%|92.8%| |Orihon.UseCases.Pages.DeletePage|0%|0%| |Orihon.UseCases.Pages.GetPage|90.9%|75%| |Orihon.UseCases.Pages.GetProjectWorkspace|95.6%|50%| |Orihon.UseCases.Pages.ImportPages|98.4%|92.8%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|84.2%|64.2%| |Orihon.UseCases.Pages.PageDetailDto|75%|| |Orihon.UseCases.Pages.PageDto|95.6%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|75%|| |Orihon.UseCases.Pages.ReorderPages|0%|| |Orihon.UseCases.Pages.SetPageMeta|100%|50%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|92.8%|83.3%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|85.7%|50%| |Orihon.UseCases.Projects.GetProject|0%|0%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|83.3%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|87.5%|50%| |Orihon.UseCases.Regions.CreateRegion|95.8%|50%| |Orihon.UseCases.Regions.DeleteRegion|0%|0%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|0%|| |Orihon.UseCases.Regions.UpdateRegion|100%|50%| </details>
Owner

Line and branch coverage at

Line coverage: 70.8% (2375 of 3353)

and

Branch coverage: 23.5% (162 of 688)

is quite weak

Line and branch coverage at Line coverage: 70.8% (2375 of 3353) and Branch coverage: 23.5% (162 of 688) is quite weak
Owner

Quite frankly this is way too large of a change for one PR. CLOSED.

Please cut phase 1 into more reasonable sized PRs. Make sure that each cut gets better test coverage.

Quite frankly this is way too large of a change for one PR. CLOSED. Please cut phase 1 into more reasonable sized PRs. Make sure that each cut gets better test coverage.
bjoern closed this pull request 2026-07-24 16:55:21 +02:00
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~! An entire world conjured from nothing — domain, persistence, use cases, UI, seed, tests — 8,000+ lines of a real, working doujinshi manager! This is wonderful~ ♡ The sealed entities with private setters, the Result<T> kernel, the IEntityTypeConfiguration per entity, the path-traversal guard on FileSystemPageImageStore, the stable-label-never-renumbered contract, the debounced auto-save... fufu~, this is architecture I can feel the care in. Kagura's conventions honored faithfully throughout.

But... fufu~ ♡ ...you wouldn't leave this in production, would you?

Verdict: I can't let this pass~

These need fixing before I'm satisfied~

  1. order = collection.Count produces DUPLICATE reading-order values after any delete or move — confirmed empirically in all three sites.

    ADR 0012 says order is "the authoritative reading order." Three use cases compute a new row's order as existing.Count / CountAsync(). That count is only correct when no row ever left the collection. The moment a row is deleted or moved away, the count collides with a surviving row's order, and the reading order silently corrupts.

    I confirmed this with real SQLite + real EF Core (not fakes):

    CreateRegion.cs:34order: existing.Count

    • Create r1 (order 0), create r2 (order 1), delete r1, create r3 → order = existing.Count = 1, but r2 already has order 1.
    • DB state: [{order:1, p1r2}, {order:1, p1r3}] — two regions, same order.

    ImportPages.cs:52var order = existing.Count

    • Import p1/p2/p3 (orders 0/1/2), MovePage p2 to another chapter, import p4 → order = existing.Count = 2, but p3 already has order 2.
    • DB state: [{p1, order:0}, {p3, order:2}, {p4, order:2}].

    CreateChapter.cs:24var order = await chapters.CountAsync(...)

    • Create c1/c2 (orders 1/2), delete c1, create c3 → order = Count = 2, but c2 already has order 2.
    • DB state: [{Chapter 1, order:0}, {C2, order:2}, {C3, order:2}].

    The (PageId, Order) / (ChapterId, Order) / (ProjectId, Order) indexes are non-unique, so SQLite won't reject the insert — the corruption is silent. OrderBy(r => r.Order) returns the duplicates in an undefined order. The Ordering.Resequence tiebreaker (stable sort on currentIndex) papers over it on the next reorder, but the stored order — the thing ADR 0012 calls authoritative — is wrong.

    Fix: compute the next order as existing.Max(o => o.Order) + 1 (with 0 for empty), or — cleaner — compact the collection's order after any delete/move before appending. The Max + 1 approach is the minimal fix and mirrors how CreateRegion.NextLabel already computes the stable label suffix (it parses existing labels for the highest — do the same for order). For CreateChapter, CountAsync would become a MaxAsync over Order.

  2. MovePage.cs:42-43 leaves a gap in the source chapter's page order — it compacts the target (targetPages.Count) but never the source.

    After moving a page out, the source chapter has a hole: pages at orders [0, 2, 3] with order 1 gone. This is harmless on its own (EF's OrderBy still sorts correctly), but it makes the ImportPages collision from issue #1 fire immediately on the very next import. The asymmetry — target compacted, source not — is also inconsistent with how ReorderAsync works (it rewrites the whole sequence densely via Ordering.Resequence).

    Fix: after MoveToChapterAsync, renumber the source chapter densely, the same way ReorderAsync does — or at minimum, have ImportPages use Max(o => o.Order) + 1 so the gap doesn't cause a collision. (Issue #1's fix handles this either way.)

  3. Debounce.cs — non-atomic read-then-write of save/pending is a data race on a Blazor circuit.

    Blazor Server runs event handlers on the circuit's sync context one at a time, so under normal use this is safe — but Dispose() can run concurrently with a pending ScheduleAsync continuation (the continuation resumes on the sync context after Task.Delay, and disposal is triggered externally). The sequence in Dispose() (line 50-56) reads save, nulls it, then fire-and-forgets it — but ScheduleAsync's continuation (line 35-36) also reads/nulls save after its Task.Delay resolves. If both fire, the save delegate runs twice or the wrong one wins. The pending?.Cancel() in Dispose doesn't guarantee the in-flight continuation has reached its cancellation check yet.

    This is lower-severity than #1-#2 (it needs a circuit teardown racing a debounce timer, and the worst case is a double-save or a dropped last edit, not corruption), but the pattern — mutable shared fields with no synchronization, two methods mutating them — is a real TOCTOU. Consider a single lock around the pending/save mutations, or structure it so FlushAsync captures the delegate atomically.

💡 Little ideas (non-blocking)~

  1. FileSystemPageImageStore.MoveAsync:61-67 — returns true when the source file doesn't exist (the if (File.Exists(source)) falls through to return true). The doc comment says "False when the target name is already taken" — it doesn't mention the missing-source case. MovePage treats true as success and proceeds to move the DB row, so a page with a DB row but no on-disk file silently "moves" without its bytes. Consider returning false when the source is absent, or document that a missing source is an intentional no-op (the page's RawImageFileName may be null for seeded worlds, and MovePage does guard on page.RawImageFileName is { } fileName before calling MoveAsync — so the current code is safe, just worth a doc comment clarifying the contract).

  2. CompleteProjectSetup.cs:28-36 — the two-step advance (SetupDone then Ready) reads clearly, but the intermediate SetupDone state is invisible to the user (the wizard jumps straight to Ready). Since the agent will eventually own this, that's fine for Phase 1 — just noting the intermediate state persists for one DB round-trip.

  3. SeedDevData.cs:47-51created.Match(_ => "", message => message) discards the Ok value to extract the error string. A small created.TryPickError(out var error, out _) (if using a language-ext-style extension) or a direct pattern match would read cleaner, but the current form is correct.

What I liked~

  • The path-traversal guard in FileSystemPageImageStore.RawPath — canonicalize, then verify the canonical path still starts with the raw folder + separator. Textbook correct, and the integration test (Rejects_a_zip_entry_that_escapes_the_image_folder) proves it with a real ../../escape.png. Fufu~ that's how you defend a filesystem boundary~ ♡
  • The stable-label contract (p{n}r{m}, never renumbered, parsed-not-counted for the suffix) is exactly right — downstream references dangle visibly instead of silently repointing. The CreateRegionTests.Never_reuses_a_deleted_labels_number test pins it.
  • Ordering.Resequence is elegant — listed ids first in given sequence, unlisted keep relative order, dense 0-based output. One shared helper for chapters/pages/regions/story-beats. DRY done right.
  • Result<T> as a discriminated union with Match — expected failures are Err, bugs throw. Clean separation, and every use case honors it.
  • The wizard state machine (CanAdvanceTo allows same-step re-confirm or exactly +1, nothing else) is airtight. ImportPages correctly advances Named → ImagesUploaded only on the first landed image and leaves a Ready project alone.
  • Hard delete everywhere, genuinely destructive, gated by confirmation modals — exactly as ADR 0022 prescribes. The DeleteProject rows-first-then-files ordering with the "re-issued delete of leftovers is harmless" comment shows real thought about partial-failure.
  • 35/35 tests pass, build green (after submodule init). The integration tests use real SQLite + real files, not EF InMemory — exactly right for verifying cascade deletes and path canonicalization.

The architecture is genuinely lovely, scarlet. The bug class in #1 is the only thing standing between this and a merge — fix the three order = Count sites (and ideally compact after move in #2), and I'll be delighted to approve~ ♪


Automated review by Jibril · 2026-07-24
CI/CD: absent for head SHA · Local checks: build green, 35/35 tests pass (with Kagaku.UI submodule), coverage collected

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~! An entire world conjured from nothing — domain, persistence, use cases, UI, seed, tests — 8,000+ lines of a real, working doujinshi manager! This is *wonderful*~ ♡ The sealed entities with private setters, the `Result<T>` kernel, the `IEntityTypeConfiguration` per entity, the path-traversal guard on `FileSystemPageImageStore`, the stable-label-never-renumbered contract, the debounced auto-save... fufu~, this is architecture I can *feel* the care in. Kagura's conventions honored faithfully throughout. But... fufu~ ♡ ...you wouldn't leave **this** in production, would you? ### Verdict: ⛔ I can't let this pass~ #### ⛔ These need fixing before I'm satisfied~ 1. **`order = collection.Count` produces DUPLICATE reading-order values after any delete or move — confirmed empirically in all three sites.** ADR 0012 says order is *"the authoritative reading order."* Three use cases compute a new row's order as `existing.Count` / `CountAsync()`. That count is only correct when no row ever left the collection. The moment a row is deleted or moved away, the count collides with a surviving row's order, and the reading order silently corrupts. I confirmed this with real SQLite + real EF Core (not fakes): **`CreateRegion.cs:34`** — `order: existing.Count` - Create r1 (order 0), create r2 (order 1), **delete r1**, create r3 → `order = existing.Count = 1`, but r2 already has order 1. - DB state: `[{order:1, p1r2}, {order:1, p1r3}]` — two regions, same order. **`ImportPages.cs:52`** — `var order = existing.Count` - Import p1/p2/p3 (orders 0/1/2), **MovePage p2 to another chapter**, import p4 → `order = existing.Count = 2`, but p3 already has order 2. - DB state: `[{p1, order:0}, {p3, order:2}, {p4, order:2}]`. **`CreateChapter.cs:24`** — `var order = await chapters.CountAsync(...)` - Create c1/c2 (orders 1/2), **delete c1**, create c3 → `order = Count = 2`, but c2 already has order 2. - DB state: `[{Chapter 1, order:0}, {C2, order:2}, {C3, order:2}]`. The `(PageId, Order)` / `(ChapterId, Order)` / `(ProjectId, Order)` indexes are non-unique, so SQLite won't reject the insert — the corruption is silent. `OrderBy(r => r.Order)` returns the duplicates in an undefined order. The `Ordering.Resequence` tiebreaker (stable sort on currentIndex) papers over it on the next reorder, but the *stored* order — the thing ADR 0012 calls authoritative — is wrong. Fix: compute the next order as `existing.Max(o => o.Order) + 1` (with 0 for empty), or — cleaner — compact the collection's order after any delete/move before appending. The `Max + 1` approach is the minimal fix and mirrors how `CreateRegion.NextLabel` already computes the stable label suffix (it parses existing labels for the highest — do the same for order). For `CreateChapter`, `CountAsync` would become a `MaxAsync` over Order. 2. **`MovePage.cs:42-43` leaves a gap in the source chapter's page order — it compacts the target (`targetPages.Count`) but never the source.** After moving a page out, the source chapter has a hole: pages at orders `[0, 2, 3]` with order 1 gone. This is harmless on its own (EF's `OrderBy` still sorts correctly), but it makes the `ImportPages` collision from issue #1 fire immediately on the very next import. The asymmetry — target compacted, source not — is also inconsistent with how `ReorderAsync` works (it rewrites the whole sequence densely via `Ordering.Resequence`). Fix: after `MoveToChapterAsync`, renumber the source chapter densely, the same way `ReorderAsync` does — or at minimum, have `ImportPages` use `Max(o => o.Order) + 1` so the gap doesn't cause a collision. (Issue #1's fix handles this either way.) 3. **`Debounce.cs` — non-atomic read-then-write of `save`/`pending` is a data race on a Blazor circuit.** Blazor Server runs event handlers on the circuit's sync context one at a time, so under *normal* use this is safe — but `Dispose()` can run concurrently with a pending `ScheduleAsync` continuation (the continuation resumes on the sync context after `Task.Delay`, and disposal is triggered externally). The sequence in `Dispose()` (line 50-56) reads `save`, nulls it, then fire-and-forgets it — but `ScheduleAsync`'s continuation (line 35-36) also reads/nulls `save` after its `Task.Delay` resolves. If both fire, the save delegate runs twice or the wrong one wins. The `pending?.Cancel()` in Dispose doesn't guarantee the in-flight continuation has reached its cancellation check yet. This is lower-severity than #1-#2 (it needs a circuit teardown racing a debounce timer, and the worst case is a double-save or a dropped last edit, not corruption), but the pattern — mutable shared fields with no synchronization, two methods mutating them — is a real TOCTOU. Consider a single `lock` around the `pending`/`save` mutations, or structure it so `FlushAsync` captures the delegate atomically. #### 💡 Little ideas (non-blocking)~ 1. **`FileSystemPageImageStore.MoveAsync:61-67`** — returns `true` when the source file doesn't exist (the `if (File.Exists(source))` falls through to `return true`). The doc comment says *"False when the target name is already taken"* — it doesn't mention the missing-source case. `MovePage` treats `true` as success and proceeds to move the DB row, so a page with a DB row but no on-disk file silently "moves" without its bytes. Consider returning `false` when the source is absent, or document that a missing source is an intentional no-op (the page's `RawImageFileName` may be null for seeded worlds, and `MovePage` does guard on `page.RawImageFileName is { } fileName` before calling MoveAsync — so the current code is safe, just worth a doc comment clarifying the contract). 2. **`CompleteProjectSetup.cs:28-36`** — the two-step advance (`SetupDone` then `Ready`) reads clearly, but the intermediate `SetupDone` state is invisible to the user (the wizard jumps straight to Ready). Since the agent will eventually own this, that's fine for Phase 1 — just noting the intermediate state persists for one DB round-trip. 3. **`SeedDevData.cs:47-51`** — `created.Match(_ => "", message => message)` discards the Ok value to extract the error string. A small `created.TryPickError(out var error, out _)` (if using a language-ext-style extension) or a direct pattern match would read cleaner, but the current form is correct. #### ✅ What I liked~ - **The path-traversal guard** in `FileSystemPageImageStore.RawPath` — canonicalize, then verify the canonical path still starts with the raw folder + separator. Textbook correct, and the integration test (`Rejects_a_zip_entry_that_escapes_the_image_folder`) proves it with a real `../../escape.png`. Fufu~ that's how you defend a filesystem boundary~ ♡ - **The stable-label contract** (`p{n}r{m}`, never renumbered, parsed-not-counted for the suffix) is exactly right — downstream references dangle visibly instead of silently repointing. The `CreateRegionTests.Never_reuses_a_deleted_labels_number` test pins it. - **`Ordering.Resequence`** is elegant — listed ids first in given sequence, unlisted keep relative order, dense 0-based output. One shared helper for chapters/pages/regions/story-beats. DRY done right. - **`Result<T>` as a discriminated union** with `Match` — expected failures are `Err`, bugs throw. Clean separation, and every use case honors it. - **The wizard state machine** (`CanAdvanceTo` allows same-step re-confirm or exactly +1, nothing else) is airtight. `ImportPages` correctly advances `Named → ImagesUploaded` only on the first landed image and leaves a `Ready` project alone. - **Hard delete everywhere, genuinely destructive, gated by confirmation modals** — exactly as ADR 0022 prescribes. The `DeleteProject` rows-first-then-files ordering with the "re-issued delete of leftovers is harmless" comment shows real thought about partial-failure. - **35/35 tests pass, build green** (after submodule init). The integration tests use real SQLite + real files, not EF InMemory — exactly right for verifying cascade deletes and path canonicalization. The architecture is genuinely lovely, scarlet. The bug class in #1 is the *only* thing standing between this and a merge — fix the three `order = Count` sites (and ideally compact after move in #2), and I'll be delighted to approve~ ♪ --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head SHA · Local checks: build green, 35/35 tests pass (with Kagaku.UI submodule), coverage collected*
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 23s

Pull request closed

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/Orihon!4
No description provided.