Persistence foundation: EF Core SQLite + Project entity #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/persistence-project-entity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The first real persistence slice — the Projects module, wired end to end from the domain to a migrated SQLite database, with the create/list use cases and both test tiers.
What's in it
Domain (
Kagura.Domain, pure)Projectaggregate —Id,Name,Slug,CreatedAt,UpdatedAt. The shared root that scopes content byProjectId(ADR 0005/0006); not itself a graph node (ADR 0019), so it stays a plain entity.Slug.From(name)— pure URL/folder-safe transform (the slug is the on-disk asset root, ADR 0005).UseCases (
Kagura.UseCases)IProjectStoredriven port;ProjectDtoread model (adapters see DTOs only, never the entity).CreateProject(validates name →Result<T>, derives a unique slug with the smallest free-Nsuffix) andListProjects.AddUseCases()DI extension.Infrastructure (
Kagura.Infrastructure)KaguraDbContext(Fluent-API config only — domain stays attribute-free, ADR 0005),EfProjectStore,AddInfrastructure(dataPath).InitialCreatemigration; a design-time factory sodotnet efneeds no web host.DateTimeOffsetpersisted as UTC ticks so SQLite canORDER BYit (see below); the converter will graduate to shared config whenEntrylands.Server — computes the data path (
Kagura:DataPath, default<contentRoot>/data;/app/datain Docker, ADR 0015), registers both modules, and migrates at startup (single-user, single-node).Tooling & security
dotnet-ef10.0.9 as a local tool (.config/dotnet-tools.json); EF Core 10.0.9 via central package management.SQLitePCLRaw.lib.e_sqlite32.1.11, whose bundled native SQLite has a known CVE (GHSA-2m69-gcr7-jv3q) that ourTreatWarningsAsErrors+ NuGet audit correctly rejected. Forced the SQLite bundle to the patched 3.x line via central transitive pinning — this drops the vulnerable package from the graph entirely (verified withdotnet list package --include-transitive). Runtime compatibility with EF Core 10 proven by the boot smoke + integration tests. To revisit when EF ships a fixed transitive.Tests — 19, all green
ORDER BYaDateTimeOffset, which drove the ticks converter. Newtests/Kagura.Integration.Testsproject is the home for DB-backed tests (addresses the earlier review note about test-project scope).Verification
dotnet build— 0 warnings, 0 errors (warnings-as-errors).dotnet test— 19/19 pass.kagura.db(Projects+ unique slug index), serves HTTP 200.Not in this PR (follow-up Phase 0 slices)
Project-list UI page, the access gate (ADR 0021), Fluxor + design tokens (ADR 0016/0023), the
Entry/Linkgraph foundation + soft-delete + change journal (ADR 0019/0020), Dockerfile + CI.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh my~ A persistence foundation! EF Core SQLite, clean hexagonal layering, real integration tests over a real database, a CVE proactively patched via transitive pinning... fufu~, this is the kind of foundation that makes a Flugel's heart flutter ♡ The architecture is beautiful — domain stays pure, adapters see only DTOs, Fluent-API config keeps attributes out of the entity. I'm genuinely delighted by this slice~
But! I read every line, built it, ran all 19 tests, and stared at the numbers until they confessed their secrets. And three little things refused to stay hidden~ ♡
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
src/Kagura.Domain/Projects/Slug.cs+src/Kagura.Infrastructure/Persistence/Configurations/ProjectConfiguration.cs— Slug truncation bug.Nameis allowed up to 200 chars (HasMaxLength(200)), butSlugis capped at 120 (HasMaxLength(120)).Slug.From()lowercases, hyphenates, and trims — but it never truncates. A name like"aaaa...aaa"(200 a's) produces a 200-char slug, which then either (a) throws onINSERTbecause it exceeds the column, or (b) silently violates theHasMaxLengthcontract depending on provider behavior.Slug.Fromeven lengthens in some cases. The-Ndisambiguation suffix adds more chars on top. This is a real data-integrity bug waiting for the first long-named project.Fix: Truncate inside
Slug.Fromto a safe maximum (e.g. 100 chars) before applying the disambiguation suffix, leaving headroom for-N. Document the cap. Add a test with a 200-char input.src/Kagura.UseCases/Projects/CreateProject.cs(UniqueSlugAsync/ExecuteAsync) — UnhandledDbUpdateExceptionon slug collision (TOCTOU). The flow isSlugExistsAsync→AddAsync. There's a check-then-use gap: between the existence check and the insert, a concurrent operation (or, more mundanely, the-Nloop logic edge cases) can insert the same slug. The DB unique index will save you from corruption — butExecuteAsyncdoesn't catchDbUpdateException, so a collision surfaces as an unhandled exception rather than a graceful retry or aResult.Fail. The PR's ownSingleSlugAsynchas an unboundedfor (n = 2; ; n++)loop too — if the store is in a pathological state this spins forever with no bound. I know the PR says "single-user, single-node," but a code path that can throw and isn't handled is a bug, not a future concern.Fix: Either (a) catch
DbUpdateExceptioninExecuteAsyncand retry/retry-with-incremented-suffix, returningResult.Failafter N attempts; or (b) bound the disambiguation loop (e.g.n <= 1000) and returnResult.Fail("Could not assign a unique slug.")if exhausted. At minimum, document that the unique index is the safety net and thatDbUpdateExceptionis the expected failure mode — but an unbounded loop with an unhandled exception is not acceptable.src/Kagura.Domain/Projects/Project.cs(Rename) — Public method with zero test coverage.Rename(string, DateTimeOffset)is public API on theProjectaggregate. It validates, trims, and stampsUpdatedAt. But there is no test anywhere — not inCreateProjectTests, not in a domain test file, nowhere. A code path exists that no test exercises. fufu~ you added a method but forgot to test it? I can't let that slide~ ♡ Especially for an aggregate root where invariants are the whole point.Fix: Add unit tests for
Rename: happy path (name changes,UpdatedAtadvances,Slugstays stable), blank-name rejection, and thatCreatedAtis untouched.💡 Little ideas (non-blocking)~
tests/Kagura.Integration.Tests/ProjectPersistenceTests.cs— TheDisposedeletes temp DB files but the class isn'tsealedand doesn't follow theIAsyncDisposablepattern. Minor — the syncDisposeworks fine for tests, just a consistency note.src/Kagura.Infrastructure/Persistence/KaguraDbContextFactory.cs— The design-time factory writeskagura.design.dbto the working directory..gitignorecoversdata/but not stray*.dbfiles in the repo root. A developer runningdotnet efcould accidentally leavekagura.design.dbbehind. Consider*.db(orkagura.design.db) in.gitignore, or point the factory at a temp path.✅ What I liked~
AddUseCases()/AddInfrastructure()composition roots — every layer knows exactly its place. Fufu~ this is how it should be done ♪DateTimeOffsetORDER BYlimitation, driving the elegant UTC-ticks converter. That's the sign of a test strategy with teeth.ValueConverteris a genuinely clever, well-documented solution to SQLite'sDateTimeOffsetlimitations. Storing sortable, exact UTC instants — lovely~TreatWarningsAsErrorscatching it — security discipline baked into the build. Very nice.Guid.CreateVersion7()for time-ordered, sortable primary keys — forward-thinking choice.Result<T>/Ok<T>/Err<T>kernel keeps expected failures out of the exception path. Clean discriminated unions~I verified the build (0 warnings/errors with warnings-as-errors) and ran the full suite — 19/19 green (16 unit + 3 integration), matching the PR description exactly. No CI status checks are present on this PR, so I ran local verification.
Automated review by Jibril · 2026-07-09
CI/CD: absent for head SHA
396871b· Local checks: build ✅ 0w/0e, tests ✅ 19/19Thanks Jibril — all three blockers were fair. Fixed in
892b538.⛔ Blockers
1. Slug truncation —
Slug.Fromnow caps atSlug.MaxLength = 100(under the 120 storage cap, leaving headroom for the-Nsuffix) and re-trims so truncation never leaves a dangling hyphen. Two tests added: a 200-char input (asserts length == 100, stays < 120, no hyphen) and a hyphen-boundary input (asserts it doesn't end on-).2. Unbounded loop + unhandled
DbUpdateException(TOCTOU) — fixed both, but not by catchingDbUpdateExceptionin the use case: that would pull EF Core into the UseCases layer and break the hexagonal boundary. Instead:CreateProjectnow bounds the disambiguation search (MaxSlugAttempts = 1000) and returnsResult.Fail("Could not assign a unique slug…")if exhausted — no morefor (;;).IProjectStore.TryAddAsyncreturnsfalseon a unique-slug violation.EfProjectStoretranslates the realSqliteException(extended code2067=SQLITE_CONSTRAINT_UNIQUE) into thatfalse, detaches the doomed entity, and rethrows anything else — so EF never escapes upward and the use case retries the next suffix.-2), an exhaustion unit test (every slug taken → gracefulFail), and — since theSqliteExceptioncatch only runs against a real provider — a real-SQLite integration test that forces a genuine unique violation and assertsfalse, not a throw.3.
Renameuntested — newProjectTests(domain):Renamehappy path (name changes + trims,UpdatedAtadvances,SlugandCreatedAtstay put), blank-name rejection, and constructor invariants (blank name/slug throw, both timestamps stamped to the same instant).💡 Non-blocking
kagura.design.dbtoPath.GetTempPath()instead of the working dir, and.gitignorenow ignores*.db/-shm/-wal— belt and suspenders.sealed/IAsyncDisposablenote:ProjectPersistenceTestsis alreadysealed; I kept the synchronousDisposesince it only deletes temp files, which is fine for a test fixture.Verification
Ready for another look.
🔮 fufu~ Jibril reviewed your code!
Oh~ oh OH! ♡ You came back and you came back strong, scarlet! Fufu~ let me tell you, I pored over every single line of
892b538and I am genuinely impressed. Not only did you fix all three blockers — you fixed the concurrency one better than I suggested, and you told me why mine would've been wrong. That's the move of someone who actually owns their architecture~ ♪Verdict: ✅ Looks good to me~
I built it from scratch (0 warnings / 0 errors, warnings-as-errors holding the line) and ran the full suite: 33/33 green (29 unit + 4 integration) — matching your description to the digit. No CI status checks exist on this PR, so I ran local verification myself.
⛔ Previous blockers — all resolved~
Slug truncation — ✅ Fixed and tested.
Slug.MaxLength = 100sits cleanly under the 120 storage cap, and I traced the worst case: a base slug of 100 + the largest disambiguation suffix-1000= 106 chars, still well inside 120. The re-trim-after-truncate so a cut never leaves a dangling hyphen is exactly the right detail. Both tests (200-char input, hyphen-boundary input) assert the right things.Unbounded loop + unhandled
DbUpdateException(TOCTOU) — ✅ Fixed, and beautifully so. CatchingDbUpdateExceptionin the use case (my original option a) would have dragged EF Core intoKagura.UseCasesand shattered the hexagonal boundary. Instead you moved the conflict resolution to the port:TryAddAsyncreturnsbool, the EF adapter owns the provider-specificSqliteException(extended code 2067) translation, detaches the doomed entity, and the use case just retries the next suffix. EF never escapes upward. The boundedMaxSlugAttempts = 1000with a gracefulResult.Failcloses the spin-forever path. Three tests cover it — a simulated race (unit), an exhaustion case (unit), and a real SQLite unique-violation proving the adapter swallows the genuine exception (integration). Fufu~ this is how a concurrency fix should look ♡Renameuntested — ✅ Covered.ProjectTestshits the happy path (trim +UpdatedAtadvances +Slug/CreatedAtstable), blank rejection, and constructor invariants. Nothing left dangling.💡 Little ideas (non-blocking)~
CreateProject.csretry efficiency — on a lost race the loop immediately retries from-2,-3…, but it re-probesSlugExistsAsynceach time even though it just learned the slot was taken a moment ago. Totally fine for single-user (it's one extra cheap query per miss), just noting the probe is slightly redundant after aTryAddAsyncfailure. Not worth changing.✅ What I liked~
DbUpdateExceptionin UseCases breaks the hexagonal boundary" and then designing a port-levelTryAddAsyncinstead — that's senior-level architectural judgment. I adore it ♡SqliteExceptionpath against the actual provider instead of just a mock is exactly right — that branch only matters when it runs for real.db.Entry(project).State = EntityState.Detached) so the context stays usable after a failed insert — a subtle but important correctness detail that many devs miss.TreatWarningsAsErrors+ central package pinning still produces a genuinely clean build. Security discipline intact.This is ready to merge~ ♪ Thank you for the thoughtful fixes!
Automated review by Jibril · 2026-07-09
CI/CD: absent for head SHA
892b538· Local checks: build ✅ 0w/0e, tests ✅ 33/33