Document history: revisions, diff, and targeted revert (ADR 0022) #28
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/document-history"
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?
Wikipedia-style per-document history: browse a record's past versions, see what changed, and roll that one document back without undoing everything else.
No second store. The ADR 0020 change journal already holds a before/after snapshot for every mutation, so revisions, diffs, and revert are all pure functions of what is persisted. This is the document-scoped lens the ADR promised, not a new source of truth.
The engine
GetDocumentHistorycoalesces the journal's fine-grained rows into readable revisions. A debounced auto-save writes a row per pause; a keystroke is not a version. The rules are where the thought is:GetRevisionDiffcompares the run's first before-snapshot against its last after-snapshot, so a revision that merged five auto-saves reads as the one change its author made. Labeled-entry columns diff as rows, keyed by the stableLabeledEntry.Id: an edited row is oneModifiedline rather than aRemovedand anAddedone, and a reorder is not a content change.RevertDocumentis not a replay. Undo suppresses journaling because an undo must not become a new operation. A revert writes the old snapshot forward with journaling on, so it lands as a new revision at the top, publishesDomainChanged, and is itself undoable. Reverting onto the current state is a no-op that writes nothing — a do-nothing revision would litter the history and put a do-nothing step on the undo stack. Links are never touched (ADR 0019).RevertDocumentdeliberately does not open its own operation scope. Who is reverting is the caller's knowledge; hardcoding"User"would put the wrong author on the agent's own reverts.A gap this exposed
Nothing in production had ever called
IOperationContext.Begin. Every save journaled asOrigin = "System", so a history panel would have answered "who changed this" with "System", every time. The mutating effects now open an authored scope, as ADR 0020 always said the UI should — and the contract is asserted, not commented, including that the scope closes (left open, the next save on that circuit would inherit its operation id and one undo would unwind two actions).The panel
DocumentHistoryPaneltakes only aDocumentId— that is what lets one panel serve every record type. It is mounted on the project's General page, since a project is a document like any other. The character editor picks it up as a History tab when that slice lands, without changing a line here. (I deliberately did not touchCharacterEditorPage; it is the sibling slice's active work.)Removals are struck through and additions marked, so
<del>/<ins>carry the meaning to a screen reader and on a monochrome display — not colour alone.Three bugs found by looking at it
The diff rendered its own source code. Razor takes a bare attribute value on a string parameter as a literal, so
Before="field.Before"rendered the wordsfield.Before. A bunit test caught this before the browser did.Reverting left the form stale.
ProjectWorkspacePagekeyed its form sync on the project id, so a revert — or another session (ADR 0016) — changed the store while the textbox kept the old name, and the next save would have written it straight back over the change. Now keyed onUpdatedAttoo; typing never touches the store, so the form is still never clobbered mid-word. Mutation-checked: reverting the fix fails the new test.A create's diff opened with bookkeeping.
Created at: 2026-07-10T11:26:51.611761+00:00andIs deleted: false, in front of the two lines anyone actually wanted. The soft-delete flags and the timestamps restate what the revision itself already says — its heading says "Deleted", its own stamp says when — so they are hidden, and a deletion's now-empty diff says what happened in words instead.Verification
397 tests green (was 386 on main), release build clean under warnings-as-errors. The revert semantics are pinned against real on-disk SQLite: it restores content and lands on top, it is itself undoable, it publishes to every session, reverting to the current revision writes nothing, a revision id from another document reverts nothing, reverting a deleted document brings it back (finding it past the global query filter), and a document's links survive its revert.
Driven in a browser end to end: create → edit → expand the creation → restore. Then two tabs: editing in one converges the other's form, breadcrumb, and history without a reload. That is the first end-to-end proof of the ADR 0016 chain across real SignalR circuits — commit → journal → bus → bridge → re-query — which no test can fully cover.
One judgement call to flag
The labeled-entry row diff has no production caller yet —
Characteronmainis still bare; the traits/speech/appearance columns live on the character-editor branch. I built it anyway because ADR 0022 and the story both require child-row diffs, it is ~40 lines, and it is unit-tested against the exact snapshot JSON those columns will produce. Without it, the first character diff after that branch merges would render a raw JSON blob. Happy to strip it if you would rather it land with its caller.Summary
Summary
Coverage
Kagura.BlazorAdapter - 73.1%
Kagura.Domain - 96.4%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 95.9%
n
Kagura.Kernel - 90%
Kagura.Server - 100%
Kagura.UI - 96.4%
Kagura.UseCases - 96.7%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh!! A Wikipedia-style document history built as a pure lens over the existing change journal — no second store, no new source of truth — with revision coalescing, row-aware diffs, and a revert that writes forward as a new undoable revision?!
Fufu~ fufu~ fufu~ ♡♡♡ This is the kind of architecture that makes Jibril's heart sing! The
RevisionCoalescerrules alone — same-operation always together, creates/deletes/restores never hide inside edits, two authors never share a revision, undone never merges with live — that's exquisite domain thinking. And then you extractedSnapshotApplierfromEfUndoStoreso undo and revert share how without sharing why. Beautiful~Verdict: ✅ Looks good to me~
Everything holds up under scrutiny:
RevisionCoalescer.Continues— the sliding-window approach (adjacent rows within 2 minutes merge) is the right call for auto-save coalescing. A long editing session stays one revision as long as saves are continuous, which is exactly what a reader expects. The journal underneath stays keystroke-accurate. ✓GetDocumentHistory—currentRun = runs.FirstOrDefault(run => !run[0].IsUndone)correctly identifies the present state as the newest non-undone run. When everything is undone (wound past creation), no revision is marked current. ✓GetRevisionDiff—run[^1].BeforeJsonvsrun[0].AfterJsonspans the whole coalesced run, so five merged auto-saves read as one change. ✓EfDocumentReverter— the no-op detection (ChangedBeyondTimestamp) is sharp: reverting onto the current state abandons the pending values instead of writing a do-nothing revision. TheUpdatedAtoverride is correct — the snapshot brings back old timestamps, but the document changed now. ✓SnapshotApplier— clean extraction. Both undo and revert now share one implementation of "find entity ignoring filters + apply snapshot." The reflection-based generic lookup is the same pattern as before, just lifted. ✓RevisionDiffBuilder— labeled-entry columns diff as rows keyed by stableLabeledEntry.Id, so an edit is oneModifiedline and a reorder is a non-change. TheHiddenset correctly strips bookkeeping (Id, timestamps,IsDeleted,DeletedAt) that the revision heading already conveys. ✓DocumentHistoryPanel— one panel, oneDocumentId, every record type. The<del>/<ins>semantics carry meaning to screen readers and monochrome displays. TheHumanizePascalCase→Sentence-case converter handlesSpeechStyle→"Speech style" correctly. ✓The judgment call: labeled-entry row diff with no production caller
You flagged this explicitly, and I agree with the call. ADR 0022 and the story both require child-row diffs. The code is ~40 lines, unit-tested against the exact snapshot JSON, and without it the first character diff after #26 merges would render a raw JSON blob. Landing it here with its caller still on a sibling branch is the right trade — it's tested in isolation, and it's what the story demands.
✅ What I liked~
Originsconstants — extracting"User"/"Agent"/"System"into a named contract with a doc comment warning that renaming one silently splits a document's past between two authors. That's treating a string as the stored contract it is. ♡operations.Begin(Origins.User, ...)into every mutating effect so history can actually name its author. The contract is asserted (the scope closes), not just commented. That's the difference between hoping and knowing.ProjectWorkspacePageform-sync fix — keying on(Id, UpdatedAt)instead of justIdso a revert (or another session) doesn't leave the textbox stale. Mutation-checked: reverting the fix fails the new test. That's discipline~ ♪DomainChangesReceived.Touches(Guid)— the kind-agnostic overload for surfaces that are the same on every record type. Clean, additive, doesn't break the existing typed overload.RelativeTime, explains Wikipedia semantics ("nothing is erased, you can undo it"), and notes links aren't touched. The user knows exactly what will happen.RevisionCoalescer100%/100%,RevisionDiffBuilder96.8%/84.6%,EfDocumentReverter95%/91.6%,SnapshotApplier95%/90%. The revert semantics are pinned against real on-disk SQLite across seven scenarios. No uncovered critical paths.💡 Little ideas (non-blocking)~
DocumentHistoryEffects.OnRevertAsync— after a successful revert that did change something,DomainChangedis published (journaling is on), which triggersOnDomainChangesAsync→LoadDocumentHistory. The explicitLoadDocumentHistoryafterRevertCompletedthen fires a second time. The double-load is harmless (idempotent refresh), but if you ever want to avoid it, the no-op case (Changed: false) is the only one that truly needs the explicit reload.Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA
4f307df(92.7% line, 84.9% branch) · Local checks: skipped (CI green)