Pure browse-state reducer for paged, searchable, category-filtered item
browsing — the shared brain of ItemSelectorModal and any embedded
browse surface.
Holds no process and runs no queries. A caller feeds it commands, it
answers with what to fetch; the caller runs the query (however it likes —
synchronously in handle_event/3 today, start_async tomorrow) and hands
the result back to ingest/4. That split is what makes every transition
testable without a database, and what lets two different LiveComponents
drive one implementation instead of drifting copies.
{state, effect} = BrowseState.command(state, {:search, "screw"})
# effect: {:fetch, query_opts, gen} | :noop
case effect do
{:fetch, opts, gen} ->
items = Search.search_items(state.search, opts)
total = Search.count_search_items(state.search, opts)
BrowseState.ingest(state, gen, items, total)
:noop ->
state
endScope is a boundary, not a default
init/1 fixes the host-supplied scope for the state's lifetime and every
query_opts/1 re-derives from it, so no later command can widen it: a
category command narrows WITHIN scope.category_uuids (and is rejected
outside it), search composes with it, and nothing can un-set
:catalogue_uuids / :only. A crafted client event can therefore never
make the fetch layer return items the host did not allow.
Generations
Every effectful command bumps gen, and ingest/4 discards results
carrying a stale one. With synchronous fetches events serialize and the
guard never fires; it exists so moving to async fetches is a drop-in
rather than a redesign — a page 1 of "a" resolving after the state reset
to "ab" must not append the wrong results.
Summary
Functions
Applies a command. Returns {state, {:fetch, query_opts, gen}} when the
caller should run a fetch, {state, :noop} otherwise.
Folds a fetched page into the state. A stale gen is discarded whole —
the state that requested it no longer exists.
Builds the initial state. opts
The Search.search_items/2 opts for the current state — always derived
from the immutable scope, never from anything a client event set directly.
Types
Functions
Applies a command. Returns {state, {:fetch, query_opts, gen}} when the
caller should run a fetch, {state, :noop} otherwise.
Commands:
:reset— first load / clear everything back to the scope.{:search, q}— replace the search string (no-op when unchanged).{:set_category, uuid | nil}— narrow to one category (nil = all within scope). Rejected with:noopwhen the uuid falls outsidescope.category_uuids— scope only ever narrows.:load_more— next page. No-op while loading or exhausted.
Folds a fetched page into the state. A stale gen is discarded whole —
the state that requested it no longer exists.
Appends are de-duplicated by uuid: offset paging over a live catalogue can re-serve a row when the sort shifts between fetches, and a duplicate card (same DOM id twice) is worse than a briefly missing one.
Builds the initial state. opts:
:scope— map with any of:catalogue_uuids,:category_uuids,:only,:statuses,:include_descendants. Fixed for the state's lifetime. Unknown keys raiseArgumentError.:per_page— page size (default 24).
The Search.search_items/2 opts for the current state — always derived
from the immutable scope, never from anything a client event set directly.