StoreHandlers
NAME
App::Moneymoor::StoreHandlers - the store's state shape, the navigation events, and the one effect every mutation in the app funnels through.
SYNOPSIS
use App::Moneymoor::StoreHandlers;
my $handlers = App::Moneymoor::StoreHandlers.new(
workspace => $workspace,
toast => -> Str $msg { $app.toast($msg) },
on-view => -> $view { $main.set-view($view) },
);
$handlers.register($store);
$store.dispatch('app/init', theme => 'nord', icons => 'unicode');
$store.tick;
# Every write in the app looks like this β a closure the effect runs.
$store.dispatch('ws/mutate-requested', action => -> {
$workspace.set-assigned('2026-08-01', $groceries-id, 40000);
});
$store.tick;
DESCRIPTION
The state shape
app/ tab 'budget' | 'accounts' | 'reports'
period 'YYYY-MM-DD' β the budget period the user is
looking at, named by its start date
theme palette name (mirrors Config)
icons glyph tier (mirrors Config)
ws/ rev monotonic recompute counter β the coarse change
token (see below)
budget/ digest BudgetView.canonical-digest β THE change token
catalogue the envelope catalogue's shape β the OTHER
change token (see below)
overspent-count categories with available < 0 in the viewed period
selected-category-id
collapsed-groups { group-id => True }
show-hidden Bool
inspector Bool
accounts/ selected-account-id 0 = All Accounts
closed-expanded Bool β the sidebar's CLOSED (n) fold
reconcile Nil | { account-id, statement }
reports/ by-group Bool β b's aggregation toggle
Note what is not here: the BudgetView itself, and the id β model
lookup hashes built from it. Those live on App::Moneymoor::Screen::Main
and reach the store only as budget/digest. The digest is documented
byte-stable and order-independent, which makes it the one thing derived
from the view that is safe to hand a subscription selector β a selector
returning the view object itself would be compared by identity and read
as "changed" on every single tick.
on-view is the seam: the recompute hands the fresh view to whoever
owns the cache, then writes the digest. Subscriptions watch the digest
and pull from the cache in their callbacks.
The digest is not enough on its own
canonical-digest is a digest of the derivation: periods, balances,
moves, account totals. It contains no category names, no group
memberships and no sort orders, and it omits a category that has no
money and no history entirely. So creating an envelope, renaming one,
moving it to another group or reordering the groups all leave the
digest byte-identical β and a table subscribed only to the digest would
not repaint.
budget/catalogue is the second token, covering exactly what the
digest does not: every group and category's id, name, group, kind, sort
order, hidden flag, carry-overspend flag and target tuple, in id order. The two together are what the
envelope grid watches. It costs two extra SELECTs per recompute,
which is the same trade Screen::Main.set-view already makes for the
lookup caches.
β¦and neither of them can see a memo
Both tokens are about the budget. Neither carries a payee's name, a
transaction's memo or its cleared state, and the register shows all
three: renaming a payee, retyping a memo or pressing c on a row
leaves the digest and the catalogue byte-identical, and a register
keyed on those two would repaint nothing.
ws/rev is the third token and the coarsest: an integer bumped by
every recompute, which is to say by every successful mutation and by
the initial load. The register and the sidebar key on it. It is
deliberately blunt β it says "something was written", not what β which
is the right resolution for a pane that rebuilds its rows wholesale
anyway, and the wrong one for the envelope grid, which is why the grid
keeps the two finer tokens.
Monotonic rather than a content hash: a counter cannot collide, and an undo that restores a previous state still has to repaint.
Reconcile mode is one path, and it clears itself
accounts/reconcile is either Nil or a two-key map naming the
account being reconciled and the statement balance typed into the
dialog. Three things about it are load-bearing.
It is written with db-replace, never db. The db effect
deep-merges, so merging Nil β or a map for a different account β
into a populated one either does nothing or leaves half the old map
behind. Leaving reconcile mode has to be an exact replacement.
It is a mode, so it has to end. Switching to another ledger, or to
another tab, exits it (accounts/select-account and
app/tab-selected clear it themselves). A statement balance is about
one account, and a "Reconciling" title left on a register the user has
walked away from is a lie the next keystroke acts on. Cleared marks
already made are kept either way: they are facts about transactions,
not about the mode.
It never survives a reload. Nothing here is persisted β the statement balance is a number the user is holding in their hand, not part of the budget.
The one mutation effect
Every gateway call in App::Moneymoor answers with a Failure on
invalid input rather than throwing, and every one of them needs the
same four things afterwards: check for failure, toast the message,
defuse the Failure, recompute. Writing that at thirty call sites is
thirty chances to forget the .so and get a re-thrown Failure at some
unrelated sink later.
So there is exactly one effect, ws/mutate, and it takes the gateway
call as a closure:
# From a keybind or a modal's submit handler:
$store.dispatch('ws/mutate-requested', action => -> {
$workspace.move-money($period, $from, $to, $pence);
});
# Or, from a handler that has other state to write in the same delta:
$store.register-handler('budget/assign-requested', -> $st, %ev {
(
(db => { budget => { selected-category-id => %ev<category-id> } }),
('ws/mutate' => { action => -> {
$workspace.set-assigned(%ev<period>, %ev<category-id>, %ev<amount>);
} }),
);
});
On Failure the message is toasted, the Failure is defused, and the
recompute is skipped β a write that did not happen must not bump the
digest, or every subscription in the app repaints to say nothing
changed. On success the budget is recomputed through
max(current-period, viewed-period) so future periods materialise,
the lookup caches are rebuilt, and the digest plus the overspent badge
count land in the store.
Effects run after the dispatching handler's own db delta has been
applied, so a handler can write the period and let the effect recompute
against it in one dispatch.
Recompute is also callable directly
recompute($store) is public because two paths need it outside the
mutation flow: the initial load (there is nothing to mutate yet) and
period navigation (the through-period moved, but no fact did). Both go
through the same code, so a bug in the badge arithmetic cannot show up
on one path and not the other.
EVENTS
app/initβ seeds the whole state tree and recomputes. Payload:theme,icons(both optional; they mirror Config).app/tab-selectedβ{ name }. Writesapp/tab.app/period-prev/app/period-nextβ stepapp/periodalong the workspace's scheme and recompute.app/period-setβ{ period }. Jump straight to a period; ignores anything that is not a real period start under the workspace's scheme rather than corrupting the store.budget/toggle-inspector/budget/toggle-hiddenβ flip the two budget-tab view switches. No recompute: neither changes a fact, only which rows get drawn.budget/select-categoryβ{ category-id }. The grid's cursor. An undefined id is legal and means "the cursor is on a group header", which is not a category.budget/toggle-groupβ{ group-id }. Fold or unfold one group inbudget/collapsed-groups. Also no recompute.accounts/select-accountβ{ account-id }. The sidebar's cursor, and so which ledger the register shows.0β the default β is All Accounts, not "none": a missing or undefined id is written through as 0 rather than left to every read site to guard.accounts/toggle-closedβ fold or unfold the sidebar'sCLOSED (n)bucket. No recompute: closed accounts are always in the derivation, this only decides whether they are drawn.accounts/reconcile-startβ{ account-id, statement }. Enter reconcile mode against a statement balance in pence. An account id of 0 (All Accounts) or an unparseable statement is refused rather than written: reconciling every ledger at once is not a thing.accounts/reconcile-exitβ leave reconcile mode, keeping every cleared mark made in it.reports/toggle-by-groupβ flip the reports tab between per-envelope and per-group aggregation. No recompute: it changes how the same derivation is added up, not any fact.ws/mutate-requestedβ{ action }. The generic doorway to thews/mutateeffect for call sites with nothing else to write; handlers with their own delta should return the effect directly instead.
EFFECTS
ws/mutateβ{ action }, the closure described above.ws/recomputeβ no payload. Recompute without mutating; whatapp/initand the period-nav handlers return.
SEE ALSO
App::Moneymoor::Screen::Main β owns the cache
on-viewfills.App::Moneymoor::Service::Workspace β the gateways being called, and the owner of the
schemeevery step here navigates along.App::Moneymoor::Util::Period β
next-period/prev-period/period-of, the navigation the handlers below delegate to.App::Moneymoor::Service::Budget β
valid-period, and theBudgetViewbeing cached.