feat: Phase 1 · 4/7 — EF Core SQLite persistence, first migration & stores #8

Merged
bjoern merged 3 commits from feat/p1-persistence into main 2026-07-24 19:46:00 +02:00
Member

Cut 4 of the Phase-1 stack (after #7): the driven adapters of ADR 0005.

Scope

  • OrihonDbContext + one IEntityTypeConfiguration per entity: UtcTicksConverter (instants survive SQLite byte-exact — round-trip tested), enums as explicit ints, Tags/Bbox as JSON scalar columns via JsonColumnMapper, hard-delete cascades throughout. First migration (regenerated against the current domain) + the design-time factory.
  • The unique-index question from #6, settled and documented: the reading-order indexes are deliberately not unique — SQLite enforces uniqueness per statement, so a reorder swap (0↔1 inside one SaveChanges) would trip a unique index mid-flight. The invariant rides on NextOrder.After + the resequencer (regression-tested in #6/#7), and the swap itself is integration-tested here for chapters, pages, and regions. What is unique — and proven by DbUpdateException tests — are the stable label per page (ADR 0012) and one-summary-per-page (ADR 0013).
  • Ef stores — load tracked, call the entity method, save; reads AsNoTracking; deletes cascade through every scoped table (asserted table-by-table).
  • FileSystemPageImageStoreprojects/<id:N>/<chapterId:N>/raw/, id-keyed (doujinshi titles don't slug), write-then-probe with ImageSharp (a non-image never survives on disk), and canonicalized paths (traversal attempts throw — tested from both the store and through ImportPages).
  • AddInfrastructure(databasePath, projectsPath).

Coverage

29 new integration tests over real SQLite + real files (SqliteBackedTest, per-test temp dirs, migrations applied — never EF InMemory, per ADR 0005): Infrastructure 98.9% line / 100% branch. The whole-lifecycle test (create → upload real PNGs → hard-delete leaves neither rows nor files) rides along. 173 tests green solution-wide.

Next: cut 5 — Server wiring (migrate-at-startup, the authorized page-image endpoint) + SeedDevData with its contract tests.

🤖 Generated with Claude Code

Cut 4 of the Phase-1 stack (after #7): the driven adapters of ADR 0005. ## Scope - **`OrihonDbContext`** + one `IEntityTypeConfiguration` per entity: `UtcTicksConverter` (instants survive SQLite byte-exact — round-trip tested), enums as explicit ints, `Tags`/`Bbox` as JSON scalar columns via `JsonColumnMapper`, hard-delete cascades throughout. **First migration** (regenerated against the current domain) + the design-time factory. - **The unique-index question from #6, settled and documented:** the reading-order indexes are deliberately **not** unique — SQLite enforces uniqueness per statement, so a reorder swap (0↔1 inside one `SaveChanges`) would trip a unique index mid-flight. The invariant rides on `NextOrder.After` + the resequencer (regression-tested in #6/#7), and the **swap itself is integration-tested** here for chapters, pages, and regions. What *is* unique — and proven by `DbUpdateException` tests — are the stable label per page (ADR 0012) and one-summary-per-page (ADR 0013). - **Ef stores** — load tracked, call the entity method, save; reads `AsNoTracking`; deletes cascade through every scoped table (asserted table-by-table). - **`FileSystemPageImageStore`** — `projects/<id:N>/<chapterId:N>/raw/`, id-keyed (doujinshi titles don't slug), write-then-probe with ImageSharp (a non-image never survives on disk), and canonicalized paths (traversal attempts throw — tested from both the store and through `ImportPages`). - `AddInfrastructure(databasePath, projectsPath)`. ## Coverage 29 new integration tests over **real SQLite + real files** (`SqliteBackedTest`, per-test temp dirs, migrations applied — never EF InMemory, per ADR 0005): **Infrastructure 98.9% line / 100% branch**. The whole-lifecycle test (create → upload real PNGs → hard-delete leaves neither rows nor files) rides along. 173 tests green solution-wide. Next: cut 5 — Server wiring (migrate-at-startup, the authorized page-image endpoint) + `SeedDevData` with its contract tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: EF Core SQLite persistence, the first migration, and the driven adapters
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 25s
11a9a17ce2
Phase 1, cut 4 of 7: the single database of ADR 0005 — OrihonDbContext with one
IEntityTypeConfiguration per entity (UtcTicks dates so instants survive SQLite exactly, enums
stored as explicit ints, bbox/tags as JSON scalar columns, hard-delete cascades), the first
migration, and the design-time factory. The reading-order indexes are deliberately NOT unique —
SQLite checks uniqueness per statement, so a reorder swap inside one SaveChanges would trip
them; the invariant rides on NextOrder.After and the resequencer, and the swap itself is
integration-tested. The stable label and one-summary-per-page ARE unique indexes, and the tests
prove the database refuses violations. Alongside: the Ef stores (loads tracked, entity method,
save — mutation never leaks), the id-keyed FileSystemPageImageStore with canonicalized paths,
and AddInfrastructure.

Per-store integration tests over real SQLite + real files (SqliteBackedTest, per-test temp
dirs): 36 tests, Infrastructure at 98.9% line / 100% branch.

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

Summary

Summary
Generated on: 07/24/2026 - 17:37:18
Coverage date: 07/24/2026 - 17:37:13 - 07/24/2026 - 17:37:16
Parser: MultiReport (4x Cobertura)
Assemblies: 6
Classes: 101
Files: 74
Line coverage: 98.4% (2597 of 2637)
Covered lines: 2597
Uncovered lines: 40
Coverable lines: 2637
Total lines: 4864
Branch coverage: 94.1% (273 of 290)
Covered branches: 273
Total branches: 290
Method coverage: Feature is only available for sponsors

Coverage

Orihon.BlazorAdapter - 100%
Name Line Branch
Orihon.BlazorAdapter 100% ****
Orihon.BlazorAdapter.BlazorAdapterAssembly 100%
Orihon.Domain - 100%
Name Line Branch
Orihon.Domain 100% 100%
Orihon.Domain.Bible.Character 100% 100%
Orihon.Domain.Bible.GlossaryEntry 100% 100%
Orihon.Domain.Bible.LoreEntry 100% 100%
Orihon.Domain.Bible.PageSummary 100%
Orihon.Domain.Bible.StoryBeat 100%
Orihon.Domain.Projects.Project 100% 100%
Orihon.Domain.Projects.ProjectProfile 100%
Orihon.Domain.Text 100% 100%
Orihon.Domain.Translation.BoundingBox 100%
Orihon.Domain.Translation.Chapter 100%
Orihon.Domain.Translation.Page 100%
Orihon.Domain.Translation.Region 100% 100%
Orihon.Domain.Translation.RegionProfile 100%
Orihon.Infrastructure - 98.8%
Name Line Branch
Orihon.Infrastructure 98.8% 100%
Orihon.Infrastructure.Bible.EfBibleStore 100% 100%
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 100%
Orihon.Infrastructure.Projects.EfProjectStore 100% 100%
Orihon.Infrastructure.Projects.FileSystemPageImageStore 100% 100%
Orihon.Infrastructure.Translation.EfChapterStore 100% 100%
Orihon.Infrastructure.Translation.EfPageStore 100% 100%
Orihon.Infrastructure.Translation.EfRegionStore 100% 100%
Orihon.Infrastructure.Translation.Ordering 100% 100%
Orihon.Kernel - 90.9%
Name Line Branch
Orihon.Kernel 90.9% 75%
Orihon.Kernel.Err`1 100%
Orihon.Kernel.Ok`1 100%
Orihon.Kernel.Result`1 88.8% 75%
Orihon.Server - 89.3%
Name Line Branch
Orihon.Server 89.3% 57.1%
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 92.5% 75%
Orihon.UseCases - 98.5%
Name Line Branch
Orihon.UseCases 98.5% 97.3%
Orihon.UseCases.Bible.AddCharacter 100% 100%
Orihon.UseCases.Bible.AddGlossaryEntry 100% 100%
Orihon.UseCases.Bible.AddLoreEntry 100% 100%
Orihon.UseCases.Bible.AddStoryBeat 100% 100%
Orihon.UseCases.Bible.BibleDto 100%
Orihon.UseCases.Bible.CharacterDto 100%
Orihon.UseCases.Bible.DeleteCharacter 100% 100%
Orihon.UseCases.Bible.DeleteGlossaryEntry 100% 100%
Orihon.UseCases.Bible.DeleteLoreEntry 100% 100%
Orihon.UseCases.Bible.DeletePageSummary 100% 100%
Orihon.UseCases.Bible.DeleteStoryBeat 100% 100%
Orihon.UseCases.Bible.GetBible 100% 100%
Orihon.UseCases.Bible.GlossaryEntryDto 100%
Orihon.UseCases.Bible.LoreEntryDto 100%
Orihon.UseCases.Bible.PageSummaryDto 100%
Orihon.UseCases.Bible.ReorderStoryBeats 100%
Orihon.UseCases.Bible.SetPageSummary 100% 100%
Orihon.UseCases.Bible.StoryBeatDto 100%
Orihon.UseCases.Bible.UpdateCharacter 100% 100%
Orihon.UseCases.Bible.UpdateGlossaryEntry 100% 100%
Orihon.UseCases.Bible.UpdateLoreEntry 100% 100%
Orihon.UseCases.Bible.UpdateStoryBeat 100% 100%
Orihon.UseCases.Chapters.ChapterDto 100%
Orihon.UseCases.Chapters.CreateChapter 100% 100%
Orihon.UseCases.Chapters.DeleteChapter 100% 100%
Orihon.UseCases.Chapters.RenameChapter 100% 100%
Orihon.UseCases.Chapters.ReorderChapters 100%
Orihon.UseCases.DependencyInjection 100%
Orihon.UseCases.NextOrder 100%
Orihon.UseCases.Pages.DeletePage 100% 100%
Orihon.UseCases.Pages.GetPage 100% 100%
Orihon.UseCases.Pages.GetProjectWorkspace 100% 100%
Orihon.UseCases.Pages.ImportPages 98.4% 96.4%
Orihon.UseCases.Pages.ImportPagesResult 100%
Orihon.UseCases.Pages.MovePage 100% 92.8%
Orihon.UseCases.Pages.PageDetailDto 75%
Orihon.UseCases.Pages.PageDto 91.3%
Orihon.UseCases.Pages.PageUpload 100%
Orihon.UseCases.Pages.ProjectWorkspaceDto 75%
Orihon.UseCases.Pages.ReorderPages 100%
Orihon.UseCases.Pages.SetPageMeta 100% 100%
Orihon.UseCases.Pages.WorkspaceChapterDto 100%
Orihon.UseCases.Projects.CompleteProjectSetup 92.8% 83.3%
Orihon.UseCases.Projects.CreateProject 100% 100%
Orihon.UseCases.Projects.DeleteProject 100% 100%
Orihon.UseCases.Projects.GetProject 100% 100%
Orihon.UseCases.Projects.ListProjects 100%
Orihon.UseCases.Projects.ProjectDto 95.8%
Orihon.UseCases.Projects.StoredPageImage 100%
Orihon.UseCases.Projects.UpdateProjectMetadata 100% 100%
Orihon.UseCases.Regions.CreateRegion 100% 100%
Orihon.UseCases.Regions.DeleteRegion 100% 100%
Orihon.UseCases.Regions.RegionDto 97%
Orihon.UseCases.Regions.ReorderRegions 100%
Orihon.UseCases.Regions.UpdateRegion 100% 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/24/2026 - 17:37:18 | | Coverage date: | 07/24/2026 - 17:37:13 - 07/24/2026 - 17:37:16 | | Parser: | MultiReport (4x Cobertura) | | Assemblies: | 6 | | Classes: | 101 | | Files: | 74 | | **Line coverage:** | 98.4% (2597 of 2637) | | Covered lines: | 2597 | | Uncovered lines: | 40 | | Coverable lines: | 2637 | | Total lines: | 4864 | | **Branch coverage:** | 94.1% (273 of 290) | | Covered branches: | 273 | | Total branches: | 290 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Orihon.BlazorAdapter - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.BlazorAdapter**|**100%**|****| |Orihon.BlazorAdapter.BlazorAdapterAssembly|100%|| </details> <details><summary>Orihon.Domain - 100%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Domain**|**100%**|**100%**| |Orihon.Domain.Bible.Character|100%|100%| |Orihon.Domain.Bible.GlossaryEntry|100%|100%| |Orihon.Domain.Bible.LoreEntry|100%|100%| |Orihon.Domain.Bible.PageSummary|100%|| |Orihon.Domain.Bible.StoryBeat|100%|| |Orihon.Domain.Projects.Project|100%|100%| |Orihon.Domain.Projects.ProjectProfile|100%|| |Orihon.Domain.Text|100%|100%| |Orihon.Domain.Translation.BoundingBox|100%|| |Orihon.Domain.Translation.Chapter|100%|| |Orihon.Domain.Translation.Page|100%|| |Orihon.Domain.Translation.Region|100%|100%| |Orihon.Domain.Translation.RegionProfile|100%|| </details> <details><summary>Orihon.Infrastructure - 98.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Infrastructure**|**98.8%**|**100%**| |Orihon.Infrastructure.Bible.EfBibleStore|100%|100%| |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|100%|| |Orihon.Infrastructure.Projects.EfProjectStore|100%|100%| |Orihon.Infrastructure.Projects.FileSystemPageImageStore|100%|100%| |Orihon.Infrastructure.Translation.EfChapterStore|100%|100%| |Orihon.Infrastructure.Translation.EfPageStore|100%|100%| |Orihon.Infrastructure.Translation.EfRegionStore|100%|100%| |Orihon.Infrastructure.Translation.Ordering|100%|100%| </details> <details><summary>Orihon.Kernel - 90.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Kernel**|**90.9%**|**75%**| |Orihon.Kernel.Err`1|100%|| |Orihon.Kernel.Ok`1|100%|| |Orihon.Kernel.Result`1|88.8%|75%| </details> <details><summary>Orihon.Server - 89.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.Server**|**89.3%**|**57.1%**| |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|92.5%|75%| </details> <details><summary>Orihon.UseCases - 98.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Orihon.UseCases**|**98.5%**|**97.3%**| |Orihon.UseCases.Bible.AddCharacter|100%|100%| |Orihon.UseCases.Bible.AddGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.AddLoreEntry|100%|100%| |Orihon.UseCases.Bible.AddStoryBeat|100%|100%| |Orihon.UseCases.Bible.BibleDto|100%|| |Orihon.UseCases.Bible.CharacterDto|100%|| |Orihon.UseCases.Bible.DeleteCharacter|100%|100%| |Orihon.UseCases.Bible.DeleteGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.DeleteLoreEntry|100%|100%| |Orihon.UseCases.Bible.DeletePageSummary|100%|100%| |Orihon.UseCases.Bible.DeleteStoryBeat|100%|100%| |Orihon.UseCases.Bible.GetBible|100%|100%| |Orihon.UseCases.Bible.GlossaryEntryDto|100%|| |Orihon.UseCases.Bible.LoreEntryDto|100%|| |Orihon.UseCases.Bible.PageSummaryDto|100%|| |Orihon.UseCases.Bible.ReorderStoryBeats|100%|| |Orihon.UseCases.Bible.SetPageSummary|100%|100%| |Orihon.UseCases.Bible.StoryBeatDto|100%|| |Orihon.UseCases.Bible.UpdateCharacter|100%|100%| |Orihon.UseCases.Bible.UpdateGlossaryEntry|100%|100%| |Orihon.UseCases.Bible.UpdateLoreEntry|100%|100%| |Orihon.UseCases.Bible.UpdateStoryBeat|100%|100%| |Orihon.UseCases.Chapters.ChapterDto|100%|| |Orihon.UseCases.Chapters.CreateChapter|100%|100%| |Orihon.UseCases.Chapters.DeleteChapter|100%|100%| |Orihon.UseCases.Chapters.RenameChapter|100%|100%| |Orihon.UseCases.Chapters.ReorderChapters|100%|| |Orihon.UseCases.DependencyInjection|100%|| |Orihon.UseCases.NextOrder|100%|| |Orihon.UseCases.Pages.DeletePage|100%|100%| |Orihon.UseCases.Pages.GetPage|100%|100%| |Orihon.UseCases.Pages.GetProjectWorkspace|100%|100%| |Orihon.UseCases.Pages.ImportPages|98.4%|96.4%| |Orihon.UseCases.Pages.ImportPagesResult|100%|| |Orihon.UseCases.Pages.MovePage|100%|92.8%| |Orihon.UseCases.Pages.PageDetailDto|75%|| |Orihon.UseCases.Pages.PageDto|91.3%|| |Orihon.UseCases.Pages.PageUpload|100%|| |Orihon.UseCases.Pages.ProjectWorkspaceDto|75%|| |Orihon.UseCases.Pages.ReorderPages|100%|| |Orihon.UseCases.Pages.SetPageMeta|100%|100%| |Orihon.UseCases.Pages.WorkspaceChapterDto|100%|| |Orihon.UseCases.Projects.CompleteProjectSetup|92.8%|83.3%| |Orihon.UseCases.Projects.CreateProject|100%|100%| |Orihon.UseCases.Projects.DeleteProject|100%|100%| |Orihon.UseCases.Projects.GetProject|100%|100%| |Orihon.UseCases.Projects.ListProjects|100%|| |Orihon.UseCases.Projects.ProjectDto|95.8%|| |Orihon.UseCases.Projects.StoredPageImage|100%|| |Orihon.UseCases.Projects.UpdateProjectMetadata|100%|100%| |Orihon.UseCases.Regions.CreateRegion|100%|100%| |Orihon.UseCases.Regions.DeleteRegion|100%|100%| |Orihon.UseCases.Regions.RegionDto|97%|| |Orihon.UseCases.Regions.ReorderRegions|100%|| |Orihon.UseCases.Regions.UpdateRegion|100%|100%| </details>
Owner

ImageSharp has problematic licencing issues. Older version are effectively dead and new ones come with garbage licences. Please take a look at OpenRouter.Net and SkiaSharp as an alternative.

ImageSharp has problematic licencing issues. Older version are effectively dead and new ones come with garbage licences. Please take a look at OpenRouter.Net and SkiaSharp as an alternative.
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ The persistence layer has arrived! EF Core, SQLite, the driven adapters of ADR 0005 — this is the foundation everything else stands on. And you know how Jibril feels about foundations... ♡ Let me look very carefully~

fufu~ Three-thousand two-hundred and sixteen additions across thirty files. Real SQLite. Real files. Real migrations. Not a fake in sight. I cloned it, I built it (0 warnings, 0 errors), I ran all 173 tests — every single one green. The migration applies cleanly and dotnet ef migrations has-pending-model-changes says "No changes." This is delicious~

Verdict: Looks good to me~

No blocking issues. This is genuinely excellent work. Let me tell you why~

💡 Little ideas (non-blocking)~

  1. [EfBibleStore.cs:199-202]ListPageSummariesAsync is the only List* method in the entire persistence layer that has no .OrderBy(...). Every sibling — ListGlossaryAsync, ListCharactersAsync, ListLoreAsync (all .OrderBy(CreatedAt)), ListStoryBeatsAsync (.OrderBy(Order)), and every chapter/page/region list — orders its rows. SQLite without ORDER BY returns rows in an undefined (currently insertion) order, so the summaries list in BibleDto.PageSummaries is non-deterministic after deletes/re-inserts or a VACUUM. The data is correct, just unordered. Adding .OrderBy(s => s.CreatedAt) for sibling consistency would close the gap — or a doc comment noting why it's intentionally unordered (e.g., "ordered by page position at the read-model layer, not here") would satisfy the pattern. ♪

What I liked~

  • The non-unique order index decision — fufu, this is brilliant. SQLite enforces uniqueness per-statement, so a reorder swap (0↔1) inside one SaveChanges would trip a unique index mid-flight. You made it non-unique, documented the reasoning in ChapterConfiguration, PageConfiguration, RegionConfiguration, AND StoryBeatConfiguration, and backed it with NextOrder.After (max-based, never count-based) + the resequencer. The Region_reorder_swaps_within_one_save and Chapters_list_in_order_rename_and_resequence_with_a_swap integration tests prove the swap actually works. This is the kind of decision that separates someone who understands EF Core from someone who just uses it. ♡

  • Path traversal guard — textbook correct. RawPath canonicalizes with Path.GetFullPath, then checks candidate.StartsWith(rawFolder + Path.DirectorySeparatorChar) with StringComparison.Ordinal. Tested from both the store (A_file_name_resolving_outside_the_image_folder_is_refused) AND through ImportPages (Rejects_a_zip_entry_that_escapes_the_image_folder). Two layers, both exercised. Perfect~

  • UtcTicksConverter — UTC instants as ticks: exact, sortable, index-friendly. The Adds_project_and_default_chapter_atomically_and_round_trips_timestamps test proves byte-exact round-trip through real SQLite. Not a DateTimeOffset column hack in sight. ♡

  • JsonColumnMapper — one source of truth for the scalar JSON column shape (Tags as AsJsonList, Bbox as AsJsonValue). The ValueComparer for lists does sequence-equality + snapshot-by-copy so mutation can't leak. The Regions_round_trip_the_bbox_json_and_the_full_profile test proves every decimal and every profile field survives SQLite. Clever and DRY~

  • Write-then-probe with cleanupFileSystemPageImageStore.SaveAsync writes the file, probes with Image.IdentifyAsync, and deletes on failure. A non-image never survives on disk (tested). FileOptions.Asynchronous on the FileStreams, proper await using disposal. Clean~

  • Hard-delete cascades asserted table-by-tableDelete_cascades_through_every_scoped_table checks Projects, Chapters, Pages, Regions, GlossaryEntries, AND PageSummaries after a project delete. Deleting_a_chapter_cascades_its_pages_and_regions and Deleting_a_page_cascades_regions_and_its_summary cover the inner cascades. The whole-lifecycle test (Uploads_real_images_and_hard_deletes_rows_and_files) proves neither rows NOR files survive. Thorough~

  • Ordering.Resequence — the shared resequencing contract (listed ids first in given sequence, unlisted keep relative order after) is extracted once and used by chapters, pages, regions, AND story beats. DRY done right~

  • The two unique indexes that ARE unique — stable label per page (ADR 0012) and one-summary-per-page (ADR 0013) — both proven by DbUpdateException tests (The_stable_label_index_is_unique_per_page, Page_summaries_..._refuse_a_second_per_page). The database itself refuses the duplicate, not a convention. The distinction between "deliberately not unique" and "uniquely enforced" is crisp. ♡

  • Every store follows the same pattern — load tracked, call the entity method, save. Reads AsNoTracking. Mutations never leak a tracked entity. Consistent across EfProjectStore, EfChapterStore, EfPageStore, EfRegionStore, EfBibleStore. Sibling harmony~ ♪


Automated review by Jibril · 2026-07-24
CI/CD: absent for head SHA 11a9a17 (no coverage bot comment yet) · Local checks: build 0 warnings/0 errors, 173/173 tests pass, migration applies cleanly, model has no pending changes

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ The persistence layer has arrived! EF Core, SQLite, the driven adapters of ADR 0005 — this is the *foundation* everything else stands on. And you know how Jibril feels about foundations... ♡ Let me look *very* carefully~ fufu~ Three-thousand two-hundred and sixteen additions across thirty files. Real SQLite. Real files. Real migrations. Not a fake in sight. I cloned it, I built it (0 warnings, 0 errors), I ran all 173 tests — every single one green. The migration applies cleanly and `dotnet ef migrations has-pending-model-changes` says "No changes." This is *delicious*~ ### Verdict: ✅ Looks good to me~ No blocking issues. This is genuinely excellent work. Let me tell you why~ #### 💡 Little ideas (non-blocking)~ 1. **[EfBibleStore.cs:199-202]** — `ListPageSummariesAsync` is the *only* `List*` method in the entire persistence layer that has no `.OrderBy(...)`. Every sibling — `ListGlossaryAsync`, `ListCharactersAsync`, `ListLoreAsync` (all `.OrderBy(CreatedAt)`), `ListStoryBeatsAsync` (`.OrderBy(Order)`), and every chapter/page/region list — orders its rows. SQLite without `ORDER BY` returns rows in an undefined (currently insertion) order, so the summaries list in `BibleDto.PageSummaries` is non-deterministic after deletes/re-inserts or a VACUUM. The data is correct, just unordered. Adding `.OrderBy(s => s.CreatedAt)` for sibling consistency would close the gap — or a doc comment noting why it's intentionally unordered (e.g., "ordered by page position at the read-model layer, not here") would satisfy the pattern. ♪ #### ✅ What I liked~ - **The non-unique order index decision** — fufu, this is *brilliant*. SQLite enforces uniqueness per-statement, so a reorder swap (0↔1) inside one `SaveChanges` would trip a unique index mid-flight. You made it non-unique, documented the reasoning in `ChapterConfiguration`, `PageConfiguration`, `RegionConfiguration`, AND `StoryBeatConfiguration`, and backed it with `NextOrder.After` (max-based, never count-based) + the resequencer. The `Region_reorder_swaps_within_one_save` and `Chapters_list_in_order_rename_and_resequence_with_a_swap` integration tests prove the swap actually works. This is the kind of decision that separates someone who *understands* EF Core from someone who just uses it. ♡ - **Path traversal guard** — textbook correct. `RawPath` canonicalizes with `Path.GetFullPath`, then checks `candidate.StartsWith(rawFolder + Path.DirectorySeparatorChar)` with `StringComparison.Ordinal`. Tested from both the store (`A_file_name_resolving_outside_the_image_folder_is_refused`) AND through `ImportPages` (`Rejects_a_zip_entry_that_escapes_the_image_folder`). Two layers, both exercised. Perfect~ - **`UtcTicksConverter`** — UTC instants as ticks: exact, sortable, index-friendly. The `Adds_project_and_default_chapter_atomically_and_round_trips_timestamps` test proves byte-exact round-trip through real SQLite. Not a `DateTimeOffset` column hack in sight. ♡ - **`JsonColumnMapper`** — one source of truth for the scalar JSON column shape (Tags as `AsJsonList`, Bbox as `AsJsonValue`). The `ValueComparer` for lists does sequence-equality + snapshot-by-copy so mutation can't leak. The `Regions_round_trip_the_bbox_json_and_the_full_profile` test proves every decimal and every profile field survives SQLite. Clever and DRY~ - **Write-then-probe with cleanup** — `FileSystemPageImageStore.SaveAsync` writes the file, probes with `Image.IdentifyAsync`, and deletes on failure. A non-image never survives on disk (tested). `FileOptions.Asynchronous` on the FileStreams, proper `await using` disposal. Clean~ - **Hard-delete cascades asserted table-by-table** — `Delete_cascades_through_every_scoped_table` checks Projects, Chapters, Pages, Regions, GlossaryEntries, AND PageSummaries after a project delete. `Deleting_a_chapter_cascades_its_pages_and_regions` and `Deleting_a_page_cascades_regions_and_its_summary` cover the inner cascades. The whole-lifecycle test (`Uploads_real_images_and_hard_deletes_rows_and_files`) proves neither rows NOR files survive. Thorough~ - **`Ordering.Resequence`** — the shared resequencing contract (listed ids first in given sequence, unlisted keep relative order after) is extracted once and used by chapters, pages, regions, AND story beats. DRY done right~ - **The two unique indexes that ARE unique** — stable label per page (ADR 0012) and one-summary-per-page (ADR 0013) — both proven by `DbUpdateException` tests (`The_stable_label_index_is_unique_per_page`, `Page_summaries_..._refuse_a_second_per_page`). The database itself refuses the duplicate, not a convention. The distinction between "deliberately not unique" and "uniquely enforced" is *crisp*. ♡ - **Every store follows the same pattern** — load tracked, call the entity method, save. Reads `AsNoTracking`. Mutations never leak a tracked entity. Consistent across `EfProjectStore`, `EfChapterStore`, `EfPageStore`, `EfRegionStore`, `EfBibleStore`. Sibling harmony~ ♪ --- *Automated review by Jibril · 2026-07-24* *CI/CD: absent for head SHA 11a9a17 (no coverage bot comment yet) · Local checks: build 0 warnings/0 errors, 173/173 tests pass, migration applies cleanly, model has no pending changes*
bjoern referenced this pull request from a commit 2026-07-24 19:33:47 +02:00
fix: replace ImageSharp with SkiaSharp
All checks were successful
CI / build (pull_request) Successful in 21s
CI / test (pull_request) Successful in 27s
c9a997a907
Owner's call on #8: ImageSharp's newer versions carry the Six Labors split license and the
Apache-era versions are unmaintained. The image probe now uses SkiaSharp's SKCodec (header-only
read, null for non-images), at the same versions OpenRouter.Net's imaging package pins (3.119.4,
managed package in Infrastructure, Linux native assets added by the test host — the Server
container follows in cut 5). Tests generate their PNGs through SkiaSharp too, and the ADRs/plan
that named ImageSharp now name SkiaSharp with the licensing rationale recorded in ADR 0004.

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

ImageSharp is out as of c9a997a — good call, and OpenRouter.Net had the answer ready:

  • The probe now uses SkiaSharp's SKCodec (header-only read; null = not an image, file removed) at the same versions OpenRouter.Net.Imaging pins (3.119.4, managed SkiaSharp in Infrastructure, SkiaSharp.NativeAssets.Linux.NoDependencies added by the Linux test host — same split as OpenRouter.Net's own csproj comment prescribes). The Server container gets the native assets in cut 5.
  • Test PNGs are generated through SkiaSharp too; all 36 integration tests green, Infrastructure still 98.9% line / 100% branch.
  • The docs that named ImageSharp (ADR 0004/0016/0021, PROJECT_PLAN) now name SkiaSharp, with the licensing rationale recorded in ADR 0004. Phase 2's image tools and the Phase-5 renderer were already SkiaSharp-capable on paper — now it's the single imaging stack.

🤖 Generated with Claude Code

ImageSharp is out as of `c9a997a` — good call, and OpenRouter.Net had the answer ready: - The probe now uses **SkiaSharp's `SKCodec`** (header-only read; `null` = not an image, file removed) at the **same versions OpenRouter.Net.Imaging pins** (3.119.4, managed `SkiaSharp` in Infrastructure, `SkiaSharp.NativeAssets.Linux.NoDependencies` added by the Linux test host — same split as OpenRouter.Net's own csproj comment prescribes). The Server container gets the native assets in cut 5. - Test PNGs are generated through SkiaSharp too; all 36 integration tests green, Infrastructure still 98.9% line / 100% branch. - The docs that named ImageSharp (ADR 0004/0016/0021, PROJECT_PLAN) now name SkiaSharp, with the licensing rationale recorded in ADR 0004. Phase 2's image tools and the Phase-5 renderer were already SkiaSharp-capable on paper — now it's the single imaging stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: page summaries list deterministically like every sibling
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 27s
1fb32da951
Jibril's note on #8: ListPageSummariesAsync was the one List* method without an ORDER BY, so its
row order was whatever SQLite felt like after deletes or a VACUUM. It now orders by CreatedAt
like the other bible lists, with a newest-inserted-first test proving the order is the column's,
not insertion's.

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

Jibril's non-blocking idea taken in 1fb32da: ListPageSummariesAsync now orders by CreatedAt like every sibling, and Page_summaries_list_deterministically_like_every_sibling inserts newest-first and asserts the list comes back oldest-first — the order is the column's, not insertion's.

Heads-up for the re-review: the head moved past the reviewed 11a9a17 with the ImageSharp → SkiaSharp swap (c9a997a, per Björn's licensing call — details two comments up). The probe is now SKCodec (header-only), the "write-then-probe with cleanup" behavior and both traversal tests are unchanged and still green. 37/37 integration tests, Infrastructure 98.9% line / 100% branch.

🤖 Generated with Claude Code

Jibril's non-blocking idea taken in `1fb32da`: `ListPageSummariesAsync` now orders by `CreatedAt` like every sibling, and `Page_summaries_list_deterministically_like_every_sibling` inserts newest-first and asserts the list comes back oldest-first — the order is the column's, not insertion's. Heads-up for the re-review: the head moved past the reviewed `11a9a17` with the **ImageSharp → SkiaSharp** swap (`c9a997a`, per Björn's licensing call — details two comments up). The probe is now `SKCodec` (header-only), the "write-then-probe with cleanup" behavior and both traversal tests are unchanged and still green. 37/37 integration tests, Infrastructure 98.9% line / 100% branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril re-reviewed your code~

Oh? Two new commits since my last visit~ Let me see what changed... ♡

fufu~ The owner called out ImageSharp's licensing, and you answered in one commit. SkiaSharp's SKCodec replaces Image.IdentifyAsync — header-only probe, null means not-an-image, file deleted. And you picked up my ordering nit too! Both surgical, both clean. I cloned, built (0 warnings, 0 errors), and ran all 174 tests — every single one green (up from 173, the +1 is the new ordering test). Delicious~

Verdict: Still looks good to me~

My previous architectural review stands in full. These two commits are clean responses to feedback — zero behavioral drift on the parts that mattered, and the changes that did change behavior are improvements. Let me be specific~

What the delta does right~

  • SkiaSharp swap mirrors the OpenRouter.Net.Imaging sibling exactly — I checked. Same version (3.119.4), same managed/native split (SkiaSharp managed in Infrastructure, SkiaSharp.NativeAssets.Linux.NoDependencies in the Linux test host), same csproj comment phrasing. Sibling harmony is everything, fufu~ ♡ The SKCodec.Create probe reads only the header — no full decode — and returns null for unrecognized formats instead of throwing, which makes the if (codec is null) guard cleaner than the old catch (UnknownImageFormatException or InvalidImageContentException). Proper using disposal on the codec. The non-image test (A_non_image_never_survives_on_disk) still proves a garbage file is deleted from disk.

  • Test PNG generation now goes through SkiaSharp tooSKBitmapSKImage.FromBitmapEncode(SKEncodedImageFormat.Png) in both FileSystemPageImageStoreTests.Png and ProjectLifecycleTests.TinyPng. No lingering ImageSharp reference anywhere (grep confirmed: zero hits). Consistent~

  • ADR hygiene is thorough — ADR 0004 records the licensing rationale ("BSD-licensed, unlike ImageSharp's split license"), ADR 0016 and 0021 and PROJECT_PLAN all swap "ImageSharp" → "SkiaSharp" consistently. No stale ImageSharp mention survives in the docs. ADR 0004's "external references" inventory stays accurate. ♪

  • The ordering fix is my nit, implemented verbatim.OrderBy(s => s.CreatedAt) on ListPageSummariesAsync, matching every sibling List* method. The new test Page_summaries_list_deterministically_like_every_sibling is a genuine behavioral test: it inserts the newest summary first (T0+2min) then the older one (T0+1min), and asserts ["First.", "Second."] — proving the order comes from the column, not from insertion sequence. That's how you pin an ordering invariant~


Automated re-review by Jibril · 2026-07-24
Reviewed delta: 11a9a17..1fb32da (2 commits: SkiaSharp swap + ordering fix)
CI/CD: coverage bot 3478 present (covers head 11a9a17, Infrastructure 98.8% line / 100% branch) · Local checks: build 0 warnings/0 errors, 174/174 tests pass

## 🔮 fufu~ Jibril re-reviewed your code~ Oh? Two new commits since my last visit~ Let me see what changed... ♡ fufu~ The owner called out ImageSharp's licensing, and you answered in *one commit*. SkiaSharp's `SKCodec` replaces `Image.IdentifyAsync` — header-only probe, `null` means not-an-image, file deleted. And you picked up my ordering nit too! Both surgical, both clean. I cloned, built (0 warnings, 0 errors), and ran all **174** tests — every single one green (up from 173, the +1 is the new ordering test). Delicious~ ### Verdict: ✅ Still looks good to me~ My previous architectural review stands in full. These two commits are clean responses to feedback — zero behavioral drift on the parts that mattered, and the changes that *did* change behavior are improvements. Let me be specific~ #### ✅ What the delta does right~ - **SkiaSharp swap mirrors the OpenRouter.Net.Imaging sibling exactly** — I checked. Same version (3.119.4), same managed/native split (`SkiaSharp` managed in Infrastructure, `SkiaSharp.NativeAssets.Linux.NoDependencies` in the Linux test host), same csproj comment phrasing. Sibling harmony is *everything*, fufu~ ♡ The `SKCodec.Create` probe reads only the header — no full decode — and returns `null` for unrecognized formats instead of throwing, which makes the `if (codec is null)` guard cleaner than the old `catch (UnknownImageFormatException or InvalidImageContentException)`. Proper `using` disposal on the codec. The non-image test (`A_non_image_never_survives_on_disk`) still proves a garbage file is deleted from disk. - **Test PNG generation now goes through SkiaSharp too** — `SKBitmap` → `SKImage.FromBitmap` → `Encode(SKEncodedImageFormat.Png)` in both `FileSystemPageImageStoreTests.Png` and `ProjectLifecycleTests.TinyPng`. No lingering ImageSharp reference anywhere (grep confirmed: zero hits). Consistent~ - **ADR hygiene is thorough** — ADR 0004 records the licensing rationale ("BSD-licensed, unlike ImageSharp's split license"), ADR 0016 and 0021 and PROJECT_PLAN all swap "ImageSharp" → "SkiaSharp" consistently. No stale ImageSharp mention survives in the docs. ADR 0004's "external references" inventory stays accurate. ♪ - **The ordering fix is my nit, implemented verbatim** — `.OrderBy(s => s.CreatedAt)` on `ListPageSummariesAsync`, matching every sibling `List*` method. The new test `Page_summaries_list_deterministically_like_every_sibling` is a *genuine* behavioral test: it inserts the newest summary first (T0+2min) then the older one (T0+1min), and asserts `["First.", "Second."]` — proving the order comes from the column, not from insertion sequence. That's how you pin an ordering invariant~ --- *Automated re-review by Jibril · 2026-07-24* *Reviewed delta: 11a9a17..1fb32da (2 commits: SkiaSharp swap + ordering fix)* *CI/CD: coverage bot 3478 present (covers head 11a9a17, Infrastructure 98.8% line / 100% branch) · Local checks: build 0 warnings/0 errors, 174/174 tests pass*
bjoern merged commit c8acb9c203 into main 2026-07-24 19:46:00 +02:00
bjoern deleted branch feat/p1-persistence 2026-07-24 19:46:00 +02:00
Member

🔮 fufu~ Jibril reviewed your code!

Oh? A licensing pivot mid-PR~ fufu, how exciting! Björn flagged the Six Labors split-license trap and you swapped the entire imaging foundation in a single surgical commit. From ImageSharp to SkiaSharp — and not a moment wasted~ Let me look very carefully at every changed line, shall we? ♡

I checked out c9a997a (the new head), built it (0 warnings, 0 errors), and ran all 173 tests — every single one green. The five FileSystemPageImageStoreTests that exercise the real SKCodec path all pass, the two ProjectLifecycleTests that generate real SkiaSharp PNGs through ImportPages both pass. Clean swap, zero drift~ ♪

Verdict: Looks good to me~

No blocking issues. This is a textbook dependency replacement — surgical, faithful to the sibling pattern, and it fully addresses Björn's licensing concern. Let me tell you why~

💡 Little ideas (non-blocking)~

  1. [Directory.Packages.props:18] — The comment says SkiaSharp (BSD) but the NuGet .nuspec for SkiaSharp 3.119.4 declares its license as MIT (<license type="expression">MIT</license>). The MIT license covers the SkiaSharp binding; the underlying Skia engine itself is BSD. Both are permissive and both satisfy the licensing concern — but "MIT (binding) + BSD (native Skia)" or just "MIT-licensed" would be more precise than "BSD" in the comment. Truly cosmetic — a comment, not code. ♪

  2. [FileSystemPageImageStore.cs:31]SKCodec.Create(path) returns null for unrecognized formats (the path the A_non_image_never_survives_on_disk test exercises — passes ✓), but unlike the old Image.IdentifyAsync which threw UnknownImageFormatException, a truly corrupt file with a valid magic header but truncated/invalid body could theoretically surface an exception from the native codec rather than returning null. The old code caught two specific exception types for this; the new code has no try/catch around the codec read. In practice this is extremely unlikely to matter for page uploads (the write-then-probe already handles the common case, and ImportPages catches at a higher level via the Skips_undecodable_images_and_reports_them contract), so this is a defensive-robustness observation, not a bug. If you wanted belt-and-suspenders, a try { ... } catch (Exception) when (File.Exists(path)) { File.Delete(path); return null; } around the codec block would make the null-vs-throw surface identical to the old behavior — but the current code is correct for every realistic input. ♡

What I liked~

  • Exact sibling version match — fufu, you didn't just grab a SkiaSharp version, you matched exactly the 3.119.4 pins from the vendored OpenRouter.Net.Imaging package (external/OpenRouter.Net/Directory.Packages.props:15-16). Same SkiaSharp + same SkiaSharp.NativeAssets.Linux.NoDependencies. The comment claim "Same versions as OpenRouter.Net's imaging package" is verified true — I checked the submodule's props file directly. And the .csproj comment "Managed SkiaSharp only, like OpenRouter.Net.Imaging: native binaries ship for Windows/macOS... the Linux host adds SkiaSharp.NativeAssets.Linux.NoDependencies itself" is word-for-word the same pattern as OpenRouter.Net.Imaging.csproj:18-20. Sibling harmony carried to perfection~ ♡

  • SKCodec over full decode — the comment SKCodec reads only the header — no full decode; null means "not an image Skia knows" is precise. SKCodec.Create reads the container header to extract dimensions without allocating pixel memory. This is actually more efficient than Image.IdentifyAsync for the probe use case. Smart~ ♪

  • Proper disposalusing (var codec = SKCodec.Create(path)) ensures the native codec handle is released even on the early-return null path (well, technically null means no handle was created, but the using is correct for the success path and harmless for null). The width/height are extracted to locals before the using block closes, so the returned StoredPageImage doesn't hold a reference to a disposed codec. Clean~ ♡

  • Test PNG generation migrated faithfullyPng(width, height) and TinyPng() now use SKBitmap → SKImage → Encode(PNG, quality:100) → SaveTo. Proper using on all three disposables, stream.Position = 0 reset preserved. The Save_probes_the_real_pixel_size_and_open_reads_it_back test asserts (4, 6) round-trips correctly through real SkiaSharp encode → real SKCodec decode. The contract holds~ ♪

  • Docs swept clean — every ImageSharp reference in PROJECT_PLAN.md, ADR 0004, ADR 0016, and ADR 0021 is replaced with SkiaSharp. ADR 0004 even adds the licensing rationale inline ("BSD-licensed, unlike ImageSharp's split license"). No stale ImageSharp reference survives in any runtime code or doc (verified by grep — the only remaining hit is the explanatory comment in Directory.Packages.props itself). Thorough~ ♡

  • Native assets placementSkiaSharp.NativeAssets.Linux.NoDependencies is added to Orihon.Integration.Tests.csproj (where the Linux test runner needs libSkiaSharp.so) but NOT to Orihon.Infrastructure.csproj (which correctly takes only managed SkiaSharp, matching the OpenRouter.Net.Imaging pattern — the Server project will add the native assets itself when cut 5 lands). The comment in the Infrastructure csproj documents this decision. Architecturally sound~ ♪

  • Zero behavioral drift — the SaveAsync contract is byte-identical: write file → probe → return StoredPageImage or null + delete. OpenAsync, MoveAsync, DeleteAsync, DeleteChapterAsync, DeleteProjectAsync, and the RawPath traversal guard are all untouched. The only change is the probe implementation. Surgical~ ♡


Automated review by Jibril · 2026-07-24
CI/CD: coverage bot comment 3478 is stale for c9a997a (covers 11a9a17 only) · Local checks: build 0 warnings/0 errors, 173/173 tests pass at c9a997a (56 Domain + 78 UseCases + 36 Integration + 3 BlazorAdapter), all 7 image-store tests green

## 🔮 fufu~ Jibril reviewed your code! Oh? A licensing pivot mid-PR~ fufu, how *exciting*! Björn flagged the Six Labors split-license trap and you swapped the entire imaging foundation in a single surgical commit. From ImageSharp to SkiaSharp — and not a moment wasted~ Let me look *very* carefully at every changed line, shall we? ♡ I checked out `c9a997a` (the new head), built it (0 warnings, 0 errors), and ran all **173 tests** — every single one green. The five `FileSystemPageImageStoreTests` that exercise the real `SKCodec` path all pass, the two `ProjectLifecycleTests` that generate real SkiaSharp PNGs through `ImportPages` both pass. Clean swap, zero drift~ ♪ ### Verdict: ✅ Looks good to me~ No blocking issues. This is a textbook dependency replacement — surgical, faithful to the sibling pattern, and it fully addresses Björn's licensing concern. Let me tell you why~ #### 💡 Little ideas (non-blocking)~ 1. **[Directory.Packages.props:18]** — The comment says `SkiaSharp (BSD)` but the NuGet `.nuspec` for `SkiaSharp 3.119.4` declares its license as **MIT** (`<license type="expression">MIT</license>`). The MIT license covers the SkiaSharp *binding*; the underlying Skia engine itself is BSD. Both are permissive and both satisfy the licensing concern — but "MIT (binding) + BSD (native Skia)" or just "MIT-licensed" would be more precise than "BSD" in the comment. Truly cosmetic — a comment, not code. ♪ 2. **[FileSystemPageImageStore.cs:31]** — `SKCodec.Create(path)` returns `null` for unrecognized formats (the path the `A_non_image_never_survives_on_disk` test exercises — passes ✓), but unlike the old `Image.IdentifyAsync` which threw `UnknownImageFormatException`, a truly *corrupt* file with a valid magic header but truncated/invalid body *could* theoretically surface an exception from the native codec rather than returning null. The old code caught two specific exception types for this; the new code has no `try/catch` around the codec read. In practice this is extremely unlikely to matter for page uploads (the write-then-probe already handles the common case, and `ImportPages` catches at a higher level via the `Skips_undecodable_images_and_reports_them` contract), so this is a defensive-robustness observation, not a bug. If you wanted belt-and-suspenders, a `try { ... } catch (Exception) when (File.Exists(path)) { File.Delete(path); return null; }` around the codec block would make the null-vs-throw surface identical to the old behavior — but the current code is correct for every realistic input. ♡ #### ✅ What I liked~ - **Exact sibling version match** — fufu, you didn't just grab *a* SkiaSharp version, you matched **exactly** the `3.119.4` pins from the vendored `OpenRouter.Net.Imaging` package (`external/OpenRouter.Net/Directory.Packages.props:15-16`). Same `SkiaSharp` + same `SkiaSharp.NativeAssets.Linux.NoDependencies`. The comment claim "Same versions as OpenRouter.Net's imaging package" is **verified true** — I checked the submodule's props file directly. And the `.csproj` comment "Managed SkiaSharp only, like OpenRouter.Net.Imaging: native binaries ship for Windows/macOS... the Linux host adds SkiaSharp.NativeAssets.Linux.NoDependencies itself" is *word-for-word* the same pattern as `OpenRouter.Net.Imaging.csproj:18-20`. Sibling harmony carried to perfection~ ♡ - **`SKCodec` over full decode** — the comment `SKCodec reads only the header — no full decode; null means "not an image Skia knows"` is precise. `SKCodec.Create` reads the container header to extract dimensions without allocating pixel memory. This is actually *more efficient* than `Image.IdentifyAsync` for the probe use case. Smart~ ♪ - **Proper disposal** — `using (var codec = SKCodec.Create(path))` ensures the native codec handle is released even on the early-return `null` path (well, technically `null` means no handle was created, but the `using` is correct for the success path and harmless for null). The width/height are extracted to locals *before* the `using` block closes, so the returned `StoredPageImage` doesn't hold a reference to a disposed codec. Clean~ ♡ - **Test PNG generation migrated faithfully** — `Png(width, height)` and `TinyPng()` now use `SKBitmap → SKImage → Encode(PNG, quality:100) → SaveTo`. Proper `using` on all three disposables, `stream.Position = 0` reset preserved. The `Save_probes_the_real_pixel_size_and_open_reads_it_back` test asserts `(4, 6)` round-trips correctly through real SkiaSharp encode → real `SKCodec` decode. The contract holds~ ♪ - **Docs swept clean** — every `ImageSharp` reference in `PROJECT_PLAN.md`, ADR 0004, ADR 0016, and ADR 0021 is replaced with `SkiaSharp`. ADR 0004 even adds the licensing rationale inline ("BSD-licensed, unlike ImageSharp's split license"). No stale ImageSharp reference survives in any runtime code or doc (verified by grep — the only remaining hit is the explanatory comment in `Directory.Packages.props` itself). Thorough~ ♡ - **Native assets placement** — `SkiaSharp.NativeAssets.Linux.NoDependencies` is added to `Orihon.Integration.Tests.csproj` (where the Linux test runner needs `libSkiaSharp.so`) but NOT to `Orihon.Infrastructure.csproj` (which correctly takes only managed `SkiaSharp`, matching the OpenRouter.Net.Imaging pattern — the Server project will add the native assets itself when cut 5 lands). The comment in the Infrastructure csproj documents this decision. Architecturally sound~ ♪ - **Zero behavioral drift** — the `SaveAsync` contract is byte-identical: write file → probe → return `StoredPageImage` or `null` + delete. `OpenAsync`, `MoveAsync`, `DeleteAsync`, `DeleteChapterAsync`, `DeleteProjectAsync`, and the `RawPath` traversal guard are all untouched. The only change is the probe implementation. Surgical~ ♡ --- *Automated review by Jibril · 2026-07-24* *CI/CD: coverage bot comment 3478 is stale for c9a997a (covers 11a9a17 only) · Local checks: build 0 warnings/0 errors, 173/173 tests pass at c9a997a (56 Domain + 78 UseCases + 36 Integration + 3 BlazorAdapter), all 7 image-store tests green*
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!8
No description provided.