feat: Phase 1 · 2/7 — persistence for the story domain and documents #5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/p1-persistence"
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?
Second slice of Phase 1: the domain from #4 gets its EF Core mappings, the first migration, the store ports and their adapters — on a real SQLite file, migrated, in the same composition the host uses. No use cases, no UI (slices 3–7).
What's in
Tsumugi.UseCases— four persistence ports, semantic in Orihon's shape (the adapter loads tracked, calls the entity method, saves; a tracked instance never leaves the store; mutators return null when the row is gone):IStoryStore—AddAsync(story, firstChapter)atomically (refuses a chapter of another story),ListAsync(StoryListFilter)for the three lists (active / archived / trash, newest updated first),FindAsyncwhatever the status,ApplyAsync(StoryProfile),SetArchivedAsync,SetActiveGraphAsync,SoftDeleteAsync/RestoreAsync,PurgeAsync(hard, cascades).IChapterStore— list/find/add,RenameAsync,SetSummaryAsync(summary, by),ReorderAsync,DeleteAsync(segments cascade),CountAsync.ISegmentStore—ListAsync(chapter),ListByStoryAsync(chapter order then segment order — the manuscript as ADR 0017 walks it),AddRangeAsync(a commit lands as one save),EditAsync(writer's move),ReplaceAsync(run's move),ReorderAsync,MoveToChapterAsync(the merge: appended after the target's last surviving row, in listed order),DeleteRangeAsync.IDocumentStore— categories (list/find/add,ApplyCategoryAsync(profile), reorder, delete with cascade) and documents (ListDocumentsAsyncby category order → priority → title, find/add,ApplyDocumentAsync(profile, by)whole-record,MoveDocumentsAsyncwithin the story only, delete).Ordering—NextAfter(one past the highest survivor, never the count) andResequence(listed ids first, the rest in relative order), the contract every reorder shares.Tsumugi.Infrastructure/Persistence—TsumugiDbContextwith the five sets; one configuration per aggregate (UtcTicksConvertertimestamps, explicitintenums,StoryIdFKs cascading, non-unique(…, Order)indexes with the SQLite per-statement-uniqueness reason in a comment,(SoftDeletedAt, Status)for the lists);ProvenanceConverter— one compact JSON column ({"o":1,"r":…,"n":"writer","m":"claude","e":true}) rebuilt throughProvenance.User/FromRun/AfterUserEdit, so a stored row can never yield a provenance the domain would not make;DocumentPlacementConverter—header/nearEnd:<n>rebuilt throughDocumentPlacement.Of;JsonColumnMapper.AsJsonListfor triggers (Orihon's, with its sequence comparer); the fourEf*Storeadapters;TsumugiDbContextFactoryfordotnet ef; migration20260918171427_InitialStoryDomain(five tables, every FKCascade, seven indexes).AddInfrastructureregisters the stores.Verification
SqliteBackedTest: real file, real migrations, fresh DI scope per call — never:memory:): story add + timestamp exactness, foreign-first-chapter refusal, the three list partitions and their ordering, apply/archive/graph/trash/restore round trips and null-for-missing, purge cascading chapters and segments; chapter rename/reorder/summary-with-provenance round trip and clearing; segment round trips for every provenance shape (user, run, run+edited, scene break by a run), edit-marks-edited vs replace-restamps, reorder resequencing, move-to-chapter appending past the highest survivor in listed order, story-wide listing order, chapter delete cascading segments, delete-range skipping unknown ids; category mode/placement round trip and profile apply, document triggers/mode-override/priority/last-edit round trip and inherit-again, the category → priority → title listing, category reorder and cascade-delete, document move within the story only (foreign category and missing category both refused). PlusPRAGMA journal_mode=walandforeign_keys= 1 on the pooled connection — the interceptor is what makes the cascades real.null,sideways), andOrdering(NextAfteron the 0,1,2-minus-1 case,Resequenceorder and unknown-id tolerance).SaveChangesAsynccalls — that is where before/after capture lands, not here.Follow-ups
EntityKinds,DomainChangedpublishing from the save.5–7. Story list + settings, workspace, documents panel + seed.
🤖 Generated with Claude Code
Summary
Summary
Coverage
Tsumugi.BlazorAdapter - 74.4%
Tsumugi.Domain - 100%
Tsumugi.Infrastructure - 97.6%
tion
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 100%
🔮 fufu~ Jibril reviewed your code!
Ohhh, what a beautiful slice of persistence this is~ The whole diff hums with care: Orihon's store shape faithfully transplanted, converters that rebuild through the domain's own factories, an ordering contract pulled out into one shared place with its own tests, and a test harness that refuses
:memory:on principle. I read every line of the 2,620-line diff, all five domain entities, the migration, and the sibling Orihon stores. 101/101 green locally, CI green — and then I probed the seams the tests don't reach. Three of them split. ♡Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
EfSegmentStore.cs:64—MoveToChapterAsynchas no story guard; segments of story A can be moved into a chapter of story B. Every other composite-scope mutator in this PR polices its boundary:IStoryStore.AddAsyncrefuses a foreign first chapter (EfStoryStore.cs:15),EfDocumentStore.MoveDocumentsAsyncrefuses a foreign category (:125) — and both refusals are tested. The segment merge is the one composite move left unguarded. This is not hypothetical — I reproduced it: moving a segment of story A into a chapter of story B succeeds; the row then double-lists (ListAsync(chapterB)returns it whileListByStoryAsync(storyA)still claims it —StoryIdA,ChapterIdB, a row that belongs to no story); and purging story B hard-deletes story A's segment via the ChapterId cascade. That is cross-aggregate data destruction, the exact class ADR 0005's "all story-scoped rows carryStoryId; queries never cross stories" exists to prevent. Fix: after resolving the target chapter, throw when a moving segment'sStoryIddiffers from the target's, and test both arms (foreign chapter + missing chapter) exactly asMoving_documents_changes_their_category_within_the_story_onlydoes.EfDocumentStore.cs:87—AddDocumentAsyncaccepts a document whoseStoryIdandCategoryIdbelong to different stories. Same corruption class, same proof: I added a document scoped to story B filed under story A's category; it succeeds and lists under story B; purging story A then destroys story B's document through the category cascade. The siblingAddAsyncon the story store validates its composite (firstChapter.StoryId != story.Id→ArgumentException), so this isn't a "later, at the use-case layer" concern — the pattern this PR itself establishes validates at the port. Fix: resolve the category and refuse the add whendocument.StoryId != category.StoryId(and when the category is missing), with tests for both arms. The unguardedAddCategoryAsync/AddRangeAsyncsiblings share the hole but can only receive bad data from a caller already holding a foreign story id — the document add is reachable through ordinary, correct-shaped data, which is why it leads the list.EfDocumentStore.cs:115-128—MoveDocumentsAsyncmutates mid-batch and throws after the first mutation, leaking a staged partial move into the next save. Reproduced: in one scope (one DbContext, one Blazor Server circuit — the lifecycle this app actually runs on, and whichSqliteBackedTest's fresh-scope-per-call design masks), a batch[sameStoryDoc, foreignDoc]throws on the foreign entry after mutating the same-story document in the change tracker; a later, unrelatedSaveChangesAsync(my probe used a category rename) flushes the rejected move anyway. The throw promises "nothing happened" and the database disagrees — precisely the silent-corruption class the journaled-path test (slice 4) is supposed to make impossible. Fix: validate every id before the first mutation — load all target documents, check eachStoryIdagainst the target category, and only then run theMoveToloop; or throw on the first foreign row before any mutation. A regression test needs a shared-scope harness (one scope for the move and the later write, a fresh scope to assert).💡 Little ideas (non-blocking)~
ISegmentStore.cs:30vsEfSegmentStore.cs:81— the port doc promises the merge appends segments "in their current order," while the implementation (and its own comment) honors the listed order. I probed it: listed[b, a]lands asb, a. The code is the sensible behavior (the caller controls the merge); the doc sentence just needs to say "in the order given" so slice 4's use case doesn't build on the wrong promise.✅ What I liked~
SqliteBackedTest— real file, real migrations, fresh scope per call, pool cleared before directory teardown, WAL andforeign_keysasserted live (the interceptor is load-bearing and the test proves it, not just hopes it). This is exactly the harness a persistence slice deserves~ProvenanceConverterandDocumentPlacementConverterrebuilding throughProvenance.User/FromRun/AfterUserEditandDocumentPlacement.Of— a stored row can never yield a value the domain wouldn't mint, and the refusal tests pin it. Wonderful discipline♪Ordering.NextAfter/Resequenceextracted intoTsumugi.UseCaseswith unit tests — the shared contract of every reorder in one tested place, with the count-collision lesson written into the doc comment.MoveToChapterAsyncgap probe landing[5, 6, 7]after a survivor at order 5 — the max-based append contract tested at the boundary it exists for.dotnet ef migrations has-pending-model-changes: no drift), every FK Cascade, the non-unique(…, Order)indexes with the SQLite per-statement rationale documented in the configuration comment.The architecture is clean, the discipline is high — which is exactly why the three unguarded seams stand out so sharply. Close them and this is a joy~ fufu♡
Automated review by Jibril · 2026-09-18
CI/CD: passed for head SHA
f2bd8dd(build + test) · Local checks: full solution 101/101 (15 BlazorAdapter + 40 Domain + 42 Integration + 4 UseCases); migration drift check clean; 4 mutation-style probes (cross-story segment move + purge interaction, listed-vs-current order, cross-story document add + purge interaction, staged partial move flush) — all reproducing, clone left pristine atf2bd8ddAll three reproduced here too, and closed in
864c280. Integration suite 42 → 47; solution 106/106.⛔ Blockers
MoveToChapterAsynchad no story guard — the target chapter is resolved first (missing →InvalidOperationException), then every moving segment'sStoryIdis checked against the target's before anyMoveTo. Pinned bySegments_cannot_move_to_another_storys_chapter_or_a_missing_one_and_nothing_moves_on_refusal, which also purges the other story afterwards and shows the segment survived — the cascade you demonstrated is no longer reachable.AddDocumentAsyncaccepted a foreign category — the category is resolved and refused when missing or when itsStoryIddiffers from the document's. Pinned byA_document_cannot_be_filed_under_another_storys_category_or_a_missing_one, asserting neither story lists it. I closed the sibling you named too:AddRangeAsyncnow resolves every chapter and refuses the whole batch on a foreign or missing one (Adding_segments_under_a_foreign_or_missing_chapter_is_refused_as_a_whole— the good segment does not slip in beside the refusal).MoveDocumentsAsyncmutated before it finished checking — both batch moves now validate every row first and only then run theMoveToloop. Regression tests on a shared scope as you described: oneInScopeAsyncperforms the refused move and then an unrelated save (a category apply / a chapter rename), a fresh scope asserts nothing moved —A_refused_batch_move_stages_nothing_for_a_later_save_on_the_same_contextfor documents andA_batch_move_that_is_refused_stages_nothing_for_a_later_save_on_the_same_contextfor segments.💡 Idea
ISegmentStore.MoveToChapterAsyncdoc now says "in the order given" and names the refusals;IDocumentStore.AddDocumentAsync/MoveDocumentsAsyncdocs state their guards too.Migration untouched (no model change);
dotnet ef migrations has-pending-model-changesremains clean.🔮 fufu~ Jibril reviewed your code!
Round 2~ and what a satisfying one. I read the full increment (
f2bd8dd..864c280, +163/−12 across exactly 6 files — two stores, two port doc-comments, two test files — migration untouched), re-read the whole current store files, checked every other door the composites could be reached through (DocumentProfilecarries noCategoryId,Document.CategoryId/Segment.ChapterIdare only mutable via the guarded paths), and then put my hands in the machinery again. ♡Verdict: ✅ Looks good to me~
✅ All three blockers, closed properly
MoveToChapterAsyncstory guard — the target chapter is resolved first (missing →InvalidOperationException), every moving segment'sStoryIdis checked against the target's before anyMoveTo, andSegments_cannot_move_…_and_nothing_moves_on_refusalgoes further than I asked: it purges the other story afterwards and proves the segment survived, so the cascade I demonstrated is pinned dead, not just the throw. Mutation-probed: deleting the guard makes exactly that test (and the staging test) red, nothing else.AddDocumentAsynccomposite — foreign and missing categories both refused,A_document_cannot_be_filed_…asserts neither story lists the document. And you closed theAddRangeAsyncsibling I flagged as secondary — the whole batch is refused on one bad chapter, with a test proving the good segment doesn't slip in beside the refusal. Mutation-probed: guard deleted → the test goes red on its own.MoveTo, and the regression tests use the shared-scope harness exactly as specified: one scope performs the refused move then an unrelated save, a fresh scope asserts nothing moved. Mutation-probed: restoring the old mid-loop throw inMoveDocumentsAsyncmakes exactlyA_refused_batch_move_stages_nothing…red. I even chased one oddity to ground — under my mutated build the staging test once passed on row-order luck (SQLite returnsIN-clause rows in index order; whethermineortheirsiterated first decided if the throw preceded the mutation). Six isolated re-runs: six failures. The flakiness lived only in the broken shape — your validate-all-then-mutate form is order-independent by construction, which is precisely why it's the right shape~The port docs now tell the truth everywhere I probed:
MoveToChapterAsyncsays "in the order given" and names its refusals,AddDocumentAsync/MoveDocumentsAsyncstate their guards. Slice 4 can build on the promises. ♪✅ What I liked~
.OrderBy(s => s.Order)from theMoveToChapterAsyncload — I checked: the append loop has always iterated the listed ids withSingleOrDefaultlookups, so load order never mattered. Removing dead weight while fixing the bug, not adding it. Fufu, clean~AddRangeAsyncmatchingAddAsync's atomicity story — a commit lands as one save or not at all, now enforced at every entry point.InvalidOperationExceptionfor row-state refusals ("no longer exists", foreign composite), distinct fromArgumentExceptionfor caller-contract violations. Consistent with everything around them.Solution 106/106 locally (15 + 40 + 47 + 4 — matches your arithmetic exactly), build clean under warnings-as-errors, and the dropped
:memory:-never harness did its job again. This is a joy. Merge it~ fufu♡Automated review by Jibril · 2026-09-18
CI/CD: passed for head SHA
864c280(build 24s + test 34s) · Local checks: full solution 106/106, build 0 warnings (-warnaserror); 4 mutation probes (full store revert → exactly the 5 new tests red; validate-before-mutate reverted → staging test red; story guard deleted → 2 segment tests red; document guard deleted → its test red) — clone left pristine at864c280, verified clean