barbatos-apsu-store
Shared state with Barbatos.Wpf.Apsu (in Barbatos.Wpf.Core), Pinia-style stores - StoreBase, StateChanged/Patch/Reset, Getter<T>, [Action] methods wrapped by IStoreActionInterceptor, AddStore, Intercep
SKILL.md.claude/skills/barbatos-apsu-store/SKILL.md
GitHubBarbatos.Wpf.Apsu - stores
Pinia's defineStore/getters/actions/plugins, ported to WPF and shipped inside
Barbatos.Wpf.Core (namespace Barbatos.Wpf.Apsu). A store is an app-lifetime DI singleton
deriving from StoreBase (a CommunityToolkit ObservableObject). Action interception is
ASP.NET Core-style middleware over a Castle DynamicProxy class proxy. Not here: view-local state,
Ref/Computed, Provide/Inject (barbatos-aquarius-view); persistence primitives such as
IPreferences (barbatos-wpf-essentials). Nothing in Apsu persists itself.
Sources of truth
- Docs: README State management
(Stores,
Getters,
Actions and action interceptors,
Removing an interceptor,
Plugins,
Registry) and
API-REFERENCE
Barbatos.Wpf.Apsu. - Code wins over docs:
src/Barbatos.Wpf.Core/Essentials/Apsu/-StoreServiceCollectionExtensions.cs(registration, proxy vs plain path, plugin/registry order),ActionInterceptor.cs(pipeline, return-type handling),StoreBase.cs,Getter.cs,IStoreActionInterceptor.cs(awaiting, retrying, the sync-action deadlock). - Samples:
samples/Barbatos.Wpf.Core.Sample/-CounterStore.cs,SampleActionLoggingInterceptor.cs,WpfProgram.cs,MainViewModel.cs(forwarding a getter).samples/Barbatos.Wpf.Aquarius.Sample/-UserStore.cs/OrderStore.cs(cross-store),WpfProgram.cs(3 global + 3 local chain), the six*Interceptor.cs,UserSessionPersistencePlugin.cs,ApsuActivityLog.cs,ApsuInteropDemoViewModel.cs. - Tests that pin behaviour:
tests/Barbatos.Wpf.Core.UnitTests/StoreBaseTests.cs(StateChanged, Patch, Reset, Getter),StoreFeatureTests.cs(singleton, laziness, registry, plugins),StoreActionInterceptorTests.cs(chain order, short-circuit, errors, async, awaiting beforenext, retry,nexton another thread, eject, sealed). - patterns.md - read when you need a complete worked example: cross-store store, argument/state/cooldown guards, error reporting, persistence plugin, exposing a getter to XAML, an ejectable toggle, or a test harness for an interceptor.
A store
Adapted from samples/Barbatos.Wpf.Core.Sample/CounterStore.cs:
public class CounterStore : StoreBase
{
int _count;
readonly Getter<string> _doubleCountLabel;
public CounterStore()
{
_doubleCountLabel = new Getter<string>(() => $"Double: {Count * 2}", this);
}
public int Count
{
get => _count;
private set => SetProperty(ref _count, value);
}
public string DoubleCountLabel => _doubleCountLabel.Value;
[Action]
public virtual void Increment() => Count++;
public override void Reset() => Count = 0;
}
builder.Services.AddStore<CounterStore>();
Shape rules - each one fails differently, some only after the first interceptor is registered:
| Rule | What breaks otherwise |
|---|---|
Not sealed |
InvalidOperationException ("must not be sealed ...") at first resolution once any interceptor applies. A global interceptor applies to every store, so adding one can break a store that worked before. |
| Exactly one public constructor, every parameter a registered service | The proxy path resolves each parameter with GetRequiredService, so >1 public constructor throws InvalidOperationException ("must have exactly one public constructor ... (found N)") and optional/default parameters are not honoured. The plain path uses ActivatorUtilities.CreateInstance, which is more lenient - again, the break appears with the first interceptor. |
public type (and any enclosing type) |
Castle cannot subclass a type its generated assembly cannot see. Every intercepted store in the tests and samples is public. |
Actions are public virtual and [Action]-marked |
Non-virtual or unmarked methods silently pass through (NonAttributedVirtualMethodIsNotIntercepted). |
Actions return void, a value, Task or Task<T> |
See Return types. |
Reset() overridden |
The default throws NotSupportedException "... does not support Reset()". Deliberate: a forgotten override fails loudly. |
State set via SetProperty, setters private |
Public setters and Patch bypass interceptors; mutate through actions when audits/guards must see every change (UserStore/OrderStore use private set). |
Registering
AddStore<TStore>(Action<StoreInterceptorBuilder<TStore>>? configureInterceptors = null)registers a singleton factory, run lazily on first resolution (StoreIsNotConstructedUntilFirstResolved). The store is resolvable asTStoreonly.- Always register with
AddStore.AddSingleton<TStore>()ornew TStore()in app code skips the proxy, the plugins and the registry. - Call
AddStore<TStore>once per type. It usesAddSingleton, notTryAdd: a second call adds a second descriptor, and a repeated callback registers its local interceptors again (they then run twice). - A store's constructor may take services and other stores (
OrderStore(HttpClient, UserStore)). It lives for the app's lifetime, so depend on singletons only. - Interceptors and plugins are read when the store is first resolved, not when
AddStoreruns, so registering them before or afterAddStoremakes no difference; only their order among themselves matters. After that the chain is frozen -Ejectis the only runtime change. - No interceptor applies -> plain instance,
GetType() == typeof(TStore)(StoreWithoutInterceptorsRegisteredUsesThePlainNonProxiedPath). Otherwise the instance is a generated subclass: test withis OrderStore, neverGetType() ==.
State, StateChanged, Patch, Reset
StateChanged(the$subscribecounterpart) fires on everyPropertyChangedthe store raises -StoreBasesubscribes to its ownPropertyChangedin its constructor. That includes a manualOnPropertyChanged(nameof(Derived)). Setting an equal value raises nothing.- Never raise the store's own
PropertyChangedfrom its ownStateChangedhandler: each raise firesStateChangedagain, recursing until the stack overflows. Patch(Action<StoreBase> mutate)batches:PropertyChangedstill fires per property, butStateChangedfires once when the outermostPatchreturns, and only if something changed. The callback parameter is typedStoreBase- ignore it and close over the typed store:store.Patch(_ => { store.Count = 1; store.Name = "a"; })(StoreBaseTests), or inside the storePatch(_ => { A = 1; B = 2; }).Patchis not a transaction: ifmutatethrows, changes already made stay,StateChangedstill fires (fromfinally), then the exception propagates. Batching is per store - another store mutated inside the callback notifies on its own.PatchandResetare not actions, so interceptors never see them. Call them from an[Action]when they must be audited or guarded.- Wrap a multi-property
ResetinPatch, asUserStore.Resetdoes: without itStateChangedfires once per property, and a plugin that persists onStateChanged(UserSessionPersistencePlugin) writes once per property too.
Getters
new Getter<T>(Func<T> compute, params StoreBase[] dependencies). List every storecomputereads,thisincluded - there is no auto-tracking. Invalidation is deliberately coarse (a simplification, not a gap to close): anyStateChangedon any dependency invalidates (GetterInvalidatesEvenWhenAnUnrelatedPropertyChanges);Valuerecomputes on the next read only.- A getter raises no change notification.
{Binding OrderStore.Summary}reads once and never updates. Expose the value through a ViewModel that re-raises it onStateChangedof every dependency store, asApsuInteropDemoViewModel.OrderSummarydoes - see patterns.md. - Forward on
StateChanged, notPropertyChanged. Outside aPatch,StoreBase's own handler runs first, so the getter is already invalidated when otherPropertyChangedsubscribers run. Inside aPatch,PropertyChangedarrives before the getter is invalidated, so a forwarded read returns the stale value and nothing re-notifies afterwards. The same applies to reading a getter inside thePatchcallback. - Create
Getter<T>only inside stores. It subscribes to each dependency'sStateChangedand has noDispose, so a getter built in a short-lived ViewModel over a singleton store is kept alive by that store forever, with everything its closure captures. In ViewModels use AquariusComputed<T>.From(getter, params INotifyPropertyChanged[] dependencies)(stores areINotifyPropertyChanged;Computed<T>isIDisposable) and read raw store state in it, not a store getter (samePatchstaleness).
Actions and interceptors
IStoreActionInterceptor.InterceptAsync(StoreActionContext context, StoreActionDelegate next)
returns Task<object?>. context carries Store, ActionName and Arguments (declaration
order). From samples/Barbatos.Wpf.Aquarius.Sample/ApsuActionLoggingInterceptor.cs (log prefix
shortened):
public async Task<object?> InterceptAsync(StoreActionContext context, StoreActionDelegate next)
{
// context.Store is the generated proxy subclass; BaseType is the store's own type.
var label = $"{context.Store.GetType().BaseType!.Name}.{context.ActionName}()";
_activityLog.Log($"[Interceptor] {label} starting");
var stopwatch = Stopwatch.StartNew();
try
{
var result = await next();
_activityLog.Log($"[Interceptor] {label} finished in {stopwatch.Elapsed.TotalMilliseconds:0}ms");
return result;
}
catch (Exception ex)
{
_activityLog.Log($"[Interceptor] {label} failed: {ex.Message}");
throw;
}
}
An interceptor only runs on a proxied store, so context.Store.GetType().Name is always the
generated type's name (OrderStoreProxy-style); GetType().BaseType!.Name is the store's own.
- Short-circuit by returning without calling
next- the action body never runs. ReturnTask.FromResult<object?>(null)forvoid/Taskactions; for value-returning actions return a boxed value of the declared type (ShortCircuitMiddlewarereturns-1forint GetDoubled()). The chain's result is cast back to the declared type, sonullfor aTask<int>action throws. - Outer interceptors still run their after-code when an inner one short-circuits - from their
side
next()just returned (AnyInterceptorBlockingInASixDeepChainPreventsTheActionAndEverythingAfterIt). - A local interceptor wraps every action of its store. Filter with
context.ActionName == nameof(OrderStore.CheckoutAsync)andreturn next();for the rest. - Read state from
context.Store(context.Store is OrderStore { OrderCount: 0 }) instead of injecting the store the interceptor wraps. - Errors: a synchronous action throws out of
next()synchronously; an async one faults its task. Anasyncinterceptor withawait next()insidetry/catchsees both.throw;propagates the original exception type (notAggregateException); returning a value from thecatchsubstitutes a fallback (MiddlewareCanCatchAnErrorAndReturnAFallbackInstead). - The store instance is the proxy, so an action calling another action on
this, a cross-store call (OrderStore.CheckoutAsync->UserStore.RefreshProfileAsync) and a plugin calling an action each run the full chain again. Expect nested log lines; guards see inner calls too.
Awaiting before next, and retrying
ActionInterceptor proceeds Castle's IInvocation only while the intercepted call is still
running on its own thread. Any later next - after an await, a retry, a call from another
thread - calls the action through the proxy again, flagged so that second pass skips the chain;
it never touches the original invocation, which Castle may still be unwinding. So next reaches
the action from any continuation and any thread
(NextCalledFromAContinuationAfterTheInterceptedCallReturnedReachesTheAction,
NextCalledFromAThreadPoolContinuationWhileTheCallIsStillReturningRunsTheActionOnce,
SynchronousActionGetsItsArgumentsAndOutParameterWhenNextRunsOnAnotherThread).
- An async precondition (a server check) may be awaited before
nextfor aTaskaction (MiddlewareCanGenuinelyAwaitAsynchronousWorkBeforeCallingNext); real work afternextworks too (MiddlewareCanGenuinelyAwaitAsynchronousWorkAfterCallingNext). Wrapping a sync action called on the UI thread, either one deadlocks it - see Return types. - Retry by calling
nextagain after a failed attempt, also when the async action failed after its own first realawait(MiddlewareCanRetryAnAsyncActionThatFailedAfterItsFirstAwait,RetryGivesUpAndPropagatesTheErrorOnceItsAttemptsAreSpent,MiddlewareCanRetryASynchronousActionByCallingNextAgain). Each call re-runs the interceptors after this one in the chain (see Registration and order) and then the action body; interceptors outside it see one call. Await one attempt before starting the next - never callnextconcurrently. - No
ConfigureAwait(false)beforenext: the action body runs on whichever thread callsnext, and would then mutate UI-bound store state from the thread pool. - Packages built before this fix called
IInvocation.Proceed()from the innermostnextwherever it ran, which re-entered the whole chain from a continuation: anawaitbeforenexthung forever with no exception (the test host had to be killed) and a retry re-ran the outer interceptors, nested. A hang there on an old package version is that bug. (IInvocation.CaptureProceedInfo()alone is not the fix: a thread-pool continuation can run while the call is still returning, and then calls run the chain twice or never complete.)
Return types and blocking
| Action returns | Chain behaviour |
|---|---|
Task / Task<T> |
Awaited properly; the caller gets a task that completes after every interceptor finished (AwaitingTheReturnedTaskGuaranteesTheMiddlewareAlreadyRanToCompletion). |
void / a value |
The proxy blocks: pipeline().GetAwaiter().GetResult(). |
ValueTask, ValueTask<T>, async void |
Treated as synchronous (next() returns the unfinished ValueTask itself, boxed): after-code runs before the work finishes and misses exceptions from its async part. Use Task. |
- Because sync actions block, an interceptor that genuinely suspends anywhere in a sync
action's chain - even after
next- waits for a UI-thread continuation while the UI thread is blocked: the classic WPF sync-over-async deadlock. Global interceptors wrap sync actions too, so a global interceptor must await nothing butnext()itself (which a sync action completes synchronously); hand async I/O (a remote log flush) off to something that runs on its own. - After
await next()the interceptor resumes on the caller's context (the pipeline deliberately omitsConfigureAwait(false)), so after-code may touch UI-bound state. Keep it that way in your interceptors.
Registration and order
| Registration | Scope | Built |
|---|---|---|
services.AddSingleton<IStoreActionInterceptor, T>() |
global | by DI |
services.AddSingleton<IStoreActionInterceptor>(sp => new T(...)) |
global | factory |
services.AddStoreInterceptor(interceptor, out InterceptorHandle handle) |
global, ejectable | instance |
AddStore<TStore>(i => i.Add<T>().Add(instance).Add(sp => new T(...))) |
local | DI / instance / factory |
AddStoreInterceptor<TStore, TInterceptor>(), AddStoreInterceptor<TStore>(instance), AddStoreInterceptor<TStore>(factory) |
local | standalone equivalents of the builder |
AddStoreInterceptor<TStore>(interceptor, out InterceptorHandle handle) |
local, ejectable | instance - the builder has no ejectable Add |
- Every global runs outside every local; each group runs in registration (
Add) order, outermost first. Globals A, B, C plus locals D, E, F giveA B C D E F ... F E D C B A(SixChainedInterceptorsAllRunInOrderWhenEveryOneCallsNext). - Order locals by cost: a guard that rejects goes before an interceptor that spends a token on
the call (
NonEmptyCartGuardInterceptorbeforeCheckoutCooldownInterceptorin the AquariusWpfProgram.cs). - Use a factory when the constructor needs a value the container cannot invent (a
TimeSpan) next to services it can. Type and factory registrations are built once, at the store's first resolution (AddStoreInterceptorFactoryIsInvokedLazilyOnlyOnceOnFirstStoreResolution). - Locals are keyed by exactly
typeof(TStore): one registered for a base store type does not apply to a derived store registered as itself. - One global instance serves every store's chain, so its fields are shared state.
- Interceptors are registered only as
IStoreActionInterceptor(keyed for locals); the concrete type is not resolvable. Surface their output through a shared injectable sink, asApsuActivityLogdoes, rather than injecting interceptors into ViewModels.
Ejecting
handle.Eject() (idempotent; handle.IsEjected reports it) turns that slot into a pass-through
for every call that reaches it from then on; the chain is not rebuilt. A call already inside the
interceptor finishes with it; one still in an outer interceptor skips it when it gets there.
Ejecting before the first call works; ejecting a short-circuiting interceptor lets later calls
reach the action. There is no re-attach, and a store's chain is fixed once the store exists, so
getting the behaviour back means a new container. For an on/off setting, give the interceptor
its own enabled flag instead; use Eject for a one-way removal. Keep the handle where the code
that ejects can reach it.
Plugins and registry
IStorePlugin.Apply(StoreBase store), registered withservices.AddSingleton<IStorePlugin, T>(), runs once perAddStorestore, in registration order, after the constructor and after the proxy is built, inside that store's DI resolution. It runs for every store - start withif (store is not UserStore userStore) return;.- A plugin can read/write state, subscribe to
StateChanged, and call actions (through the chain). It cannot wrap actions - that is an interceptor's job. - State a plugin restores is set while the store is still being resolved, before anything else
can subscribe, so its change notifications reach nobody. Whatever reacts to restored state must
read the current value when it subscribes (an "immediate" watch): in the Aquarius sample,
UserSessionPersistencePluginrestoresSelectedUserIdbeforeOrderStoreexists, soOrderStore's constructor checks_userStore.SelectedUserIdright after wiring its watch and loads that user's orders (LoadOrdersForRestoredUser). IStoreRegistry.Storesis a snapshot of the stores resolved so far, in the order each finished resolving (after its plugins; a store's dependency stores come first); a registered but never-resolved store is absent. It is registered by the firstAddStorecall and exists for introspection only - the container is the cache.
Store, ViewModel or Provide/Inject
| Need | Use |
|---|---|
| State shared by unrelated ViewModels/windows for the app's lifetime | an Apsu store |
| State owned by one screen | the ViewModel ([ObservableProperty], Aquarius Ref<T>) |
| A value scoped to one visual subtree | Aquarius Provide/Inject (barbatos-aquarius-view) |
| Derivation across stores | Getter<T> in a store |
| View-shaped derivation of store state | Computed<T> in the ViewModel, disposed with it |
Cross-store wiring (OrderStore.cs): inject the other store; "watch" it with an ordinary
PropertyChanged subscription (store-to-store subscriptions are fine - both are singletons); or
call its action directly. An async lambda handler is async void: when the change is raised on
the UI thread its exceptions reach OnDispatcherUnhandledException (barbatos-wpf-host), never a
caller. A ViewModel that is not a
singleton must unsubscribe from store events when it goes away - the store outlives it.
Symptom -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
| Interceptor never runs for an action | Method not virtual, missing [Action], store not registered via AddStore, local registered for another TStore, or handle ejected |
Fix the shape/registration; check IsEjected |
| Intercepted action never completes, no exception | An interceptor returned a task that never completes (it awaits something that never finishes); on a package built before next could run after an await, an await before next |
Find the interceptor's unfinished await; update the package |
| UI freezes when a sync action runs | An interceptor genuinely suspends inside a sync action's chain | Make the action return Task, or keep interceptors synchronous |
InvalidOperationException "must not be sealed" right after adding a global interceptor |
A sealed store now needs a proxy |
Remove sealed |
InvalidOperationException "must have exactly one public constructor ... (found N)" |
Proxy path needs one constructor | Collapse to one |
| "No service for type ..." resolving a store that worked before interceptors | Proxy path ignores optional/default constructor parameters | Register the dependency or drop the parameter |
| Castle error that the store type is not accessible | Store or an enclosing type is not public |
Make it public |
NotSupportedException "... does not support Reset()" |
Reset() not overridden (when proxied, the message names the generated type) |
Override Reset() |
| Getter-backed property never updates in the UI | Getter<T> raises no PropertyChanged |
Re-raise from a ViewModel on StateChanged |
Getter shows the old value after a Patch |
Forwarded on PropertyChanged, which precedes invalidation inside Patch |
Forward on StateChanged |
| Getter stale after another store changed | That store is not in dependencies |
List every store compute reads |
StackOverflowException in a store |
Store raises its own PropertyChanged from its StateChanged handler |
Raise on the ViewModel instead |
| ViewModel never garbage-collected | Getter<T> built in it over a singleton store, or store events never unsubscribed |
Computed<T> + Dispose; unsubscribe |
| Interceptor logs a call twice | Action called from another action / store / plugin (nested chain), or AddStore called twice |
Expected for nesting; register once |
| Logging/timing ends before an async action finishes; interceptors never see its error | Action returns ValueTask or is async void |
Return Task |
Exception (e.g. NullReferenceException, InvalidCastException) after a short-circuit |
Returned null or the wrong boxed type for a value-returning action |
Return a boxed value of the declared type |
| Log shows a generated type name instead of the store's | context.Store is the proxy subclass |
GetType().BaseType!.Name |
IStoreRegistry cannot be resolved |
No AddStore call registered it |
Register at least one store |
| Restored state did not trigger another store's watch | Plugin ran during resolution, before the watcher subscribed | Read current state at subscribe time |
Checklist
Adding a store: public, non-sealed, one public constructor over singletons; state via
SetProperty with private set; actions public virtual, [Action], returning Task when async;
Reset() overridden (multi-property in Patch); getters list every store they read; one
AddStore<TStore> call with its local interceptors in the callback; getter-backed values reach
XAML through a ViewModel forwarding StateChanged.
Adding an interceptor: await nothing but next() if it may wrap a sync action (every global
does); no ConfigureAwait(false) before next; retries call next one attempt at a time;
filter by nameof action; short-circuit with a correctly typed value; throw; after observing;
guards before token-spending interceptors; a test in the style of
StoreActionInterceptorTests.BuildStore, with async steps gated by a TaskCompletionSource
the test completes rather than by delays.
Changing src/Barbatos.Wpf.Core/Essentials/Apsu/ itself: follow barbatos-package-authoring
(stores stay the documented sealed exception; the pipeline's reflection is why the Core
.csproj suppresses IL2075/IL2060); extend the three test files; update README "State management" and the
API-REFERENCE namespace (barbatos-docs-sync); keep both samples demonstrating it. Run tests per
TFM: dotnet test tests/Barbatos.Wpf.Core.UnitTests -f net10.0-windows10.0.17763.0 --filter "FullyQualifiedName~UnitTests.Store".
Related skills
barbatos-wpf-host (builder, lifecycle hooks such as OnDispatcherUnhandledException),
barbatos-wpf-essentials (IPreferences for persistence plugins), barbatos-aquarius-view
(Ref/Computed/Watch, Provide/Inject), barbatos-wpf-testing (running and writing tests),
barbatos-package-authoring (changing the library), barbatos-docs-sync (README/API-REFERENCE).