From 469ed967095c0bdb640ec1302ed262373a4099a7 Mon Sep 17 00:00:00 2001 From: Damirados Date: Wed, 8 Apr 2026 14:23:02 +0200 Subject: [PATCH 1/4] Consolidate public docs into the README Make the README the primary user guide and remove duplicate example pages from the generated docs. --- README.md | 736 +++++++++++++++++------------ examples/counter_lookup_example.md | 162 ------- examples/emerge_lookup_example.md | 138 ------ mix.exs | 5 +- 4 files changed, 427 insertions(+), 614 deletions(-) delete mode 100644 examples/counter_lookup_example.md delete mode 100644 examples/emerge_lookup_example.md diff --git a/README.md b/README.md index 398e5db..fc74e91 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,17 @@ # Solve -Solve is a state management framework for UI-heavy Elixir applications. +Solve is a controller-graph state runtime for Elixir applications. -Solve organizes state around controller dependencies, not component hierarchy. The result is an acyclic state graph that can fan out, share upstream nodes, and terminate in multiple leaves. UI layout and state structure stay separate: the UI may be rendered as a tree, while state is modeled as an acyclic dependency graph of controllers. +It models application state as a graph of focused controllers instead of a tree +that mirrors UI structure. Each controller owns one slice of behavior and +state, exposes a plain map, and declares its dependencies explicitly. -Each controller owns one slice of behavior and state, exposes a plain map, and can depend on other controllers. Solve validates the graph on boot and rejects circular dependencies. +This keeps state structure and UI structure separate. Your UI can still render +as a tree, but your application state does not have to follow that shape. -## What Solve Gives You +## Install Solve -- Small, focused state owners instead of one large application process -- Explicit dependencies between state owners -- Derived state without pushing that logic into the UI layer -- Collection support for repeated item controllers -- Explicit cross-controller writes through dispatch and callbacks -- The ability to read state where a process needs it, which reduces the need to thread large state and handler bundles through nested UI helpers - -## Installation - -If available in Hex, add `solve` to your dependencies: +Add `solve` to your dependencies: ```elixir def deps do @@ -27,21 +21,65 @@ def deps do end ``` -## Core Ideas +## Model state around interaction + +Users do not interact with one widget in isolation. They interact with the +application as a whole. + +A click, a filter change, a draft edit, or a menu selection is presented in one +place, but it often affects several other parts of the system: + +- visible content +- counters and summaries +- enabled actions +- local editing state +- background processes + +That is why Solve models state around ownership and interaction, not around the +nearest rendered component. + +A controller is not just a bucket of values. A controller models one coherent +slice of application behavior. + +## Learn the core ideas -- **Solve app** - the coordinating `GenServer` that owns the controller graph - **controller** - a `GenServer` that owns one slice of state and behavior -- **exposed state** - the plain map a controller shares with subscribers, dependents, and UI code -- **dependency** - another controller's exposed state made available to a controller -- **callback** - a function passed from the app into a controller for explicit cross-controller writes -- **collection source** - a controller spec that manages many item controllers like `{:todo, 1}` or `{:todo, 2}` -- **Solve.Lookup** - a process-local read API that keeps a local view of Solve state and builds direct event refs +- **exposed state** - the plain map a controller shares with subscribers, + dependents, and view code +- **dependency** - another controller's exposed state made available to a + controller +- **callback** - a function passed from the app into a controller for explicit + cross-controller writes +- **collection source** - a controller spec that manages many item controllers + such as `{:todo, 1}` or `{:todo, 2}` +- **Solve app** - the coordinating `GenServer` that owns the controller graph +- **Solve.Lookup** - a process-local API for cached reads and direct event refs -Declared event handlers can take the leading subset of runtime inputs they need: `payload`, `state`, `dependencies`, `callbacks`, and `init_params`. +Declared event handlers take the leading subset of runtime inputs they need: -## Smallest Working Example +- `payload` +- `state` +- `dependencies` +- `callbacks` +- `init_params` -Start with one controller and one Solve app. +For example, an event handler may be defined at any of these arities: + +```elixir +event_name(payload) +event_name(payload, state) +event_name(payload, state, dependencies) +event_name(payload, state, dependencies, callbacks) +event_name(payload, state, dependencies, callbacks, init_params) +``` + +## Start with one controller + +Start with one controller. + +A controller is the smallest useful unit in Solve. It owns one slice of state, +handles a small set of events, and exposes a plain map for the rest of the +application to read. ```elixir defmodule MyApp.CounterController do @@ -53,11 +91,26 @@ defmodule MyApp.CounterController do def increment(_payload, state), do: %{state | count: state.count + 1} def decrement(_payload, state), do: %{state | count: state.count - 1} end +``` + +This controller models one small interaction boundary: incrementing and +decrementing a counter. + +That is the right place to begin. Start with one interaction and give it one +controller. -defmodule MyApp.State do +## Run controllers in an app + +Controllers run inside a Solve app. + +The app starts the controller graph, keeps it alive, and routes events to the +right controller instance. + +```elixir +defmodule MyApp.App do use Solve - @impl true + @impl Solve def controllers do [ controller!(name: :counter, module: MyApp.CounterController) @@ -69,181 +122,144 @@ end Start the app like any other `GenServer`: ```elixir -{:ok, app} = MyApp.State.start_link(name: MyApp.State) -``` - -In this example: - -- `MyApp.State` defines the controller graph -- `:counter` is a singleton controller source -- the controller owns its internal state -- the exposed state is the same map because it uses the default `expose/3` - -## Using Solve Directly - -The lowest-level way to interact with Solve is through `dispatch` and `subscribe`. - -```elixir -iex> {:ok, app} = MyApp.State.start_link(name: MyApp.State) -iex> Solve.subscribe(app, :counter) -%{count: 0} - -iex> :ok = Solve.dispatch(app, :counter, :increment, %{}) -iex> Solve.subscribe(app, :counter) -%{count: 1} +{:ok, app} = MyApp.App.start_link(name: MyApp.App) ``` -`Solve.dispatch/4` sends an event to a controller. `Solve.subscribe/2` returns the current exposed state and subscribes the caller to future updates. +At this point: -This API is enough when you want to work with Solve directly from another process or from tests. +- `MyApp.CounterController` models one slice of behavior +- `MyApp.App` runs that controller +- the app becomes the stable runtime entrypoint for reads, dispatch, and + subscriptions -## Reading State From A Process With Solve.Lookup +## Describe data flow in the app -When a process wants to keep a local, process-friendly view of Solve state, use `Solve.Lookup`. +The app is not only a runtime container. It also defines how controllers +interact. -`Solve.Lookup` is designed to fit Emerge's render/event loop especially well, but it is not limited to Emerge. It also supports ordinary `GenServer` processes and other long-running processes that want local cached reads and update handling. +That is the main role of the controller graph. -The main helpers are: +An app defines: -- `solve(app, target)` for singleton controllers and collection items -- `collection(app, source)` for collection sources -- `events(item)` to read a controller's direct event refs -- `event(item, name)` and `event(item, name, payload)` to build direct handler tuples +- which controllers exist +- which controllers read from others through dependencies +- which writes cross ownership boundaries through callbacks +- which repeated controller instances are materialized through collections -If you already know the id of a collection item, read it directly: +For example: ```elixir -todo = solve(app, {:todo, 42}) -``` +defmodule MyApp.App do + use Solve -Use `collection(app, :todo)` when you want the full ordered collection. + @impl Solve + def controllers do + [ + controller!(name: :task_list, module: MyApp.TaskList), + controller!( + name: :create_task, + module: MyApp.CreateTask, + callbacks: %{ + submit: fn title -> dispatch(:task_list, :create_task, title) end + } + ), + controller!( + name: :filter, + module: MyApp.Filter, + dependencies: [:task_list] + ), + controller!( + name: :task_editor, + module: MyApp.TaskEditor, + variant: :collection, + dependencies: [:task_list], + collect: fn _context = %{dependencies: %{task_list: task_list}} -> + Enum.map(task_list.ids, fn id -> + {id, [params: %{id: id, title: task_list.tasks[id].title}]} + end) + end + ) + ] + end +end +``` -## Solve.Lookup With Emerge +This graph describes data flow directly: -With Emerge, the standard `Solve.Lookup` pattern is: read state in `render/1`, bind events directly from lookup items, and rerender when Solve updates arrive. +- `:filter` reads from `:task_list` +- `:create_task` writes back to `:task_list` through a callback +- `:task_editor` materializes one controller per task +- `:task_list` remains the owner of the canonical data -```elixir -defmodule MyApp.Viewport do - use Emerge - use Solve.Lookup +Controllers implement behavior. The app defines how controllers read from and +write to each other. - @impl Viewport - def render(_state) do - counter = solve(MyApp.State, :counter) +## Keep controllers focused - row([], [ - button("+", event(counter, :increment)), - el([], text("Count: #{counter.count}")), - button("-", event(counter, :decrement)) - ]) - end +Each controller owns one coherent interaction boundary. - @impl Solve.Lookup - def handle_solve_updated(_updated, state) do - {:ok, Viewport.rerender(state)} - end -end -``` +Good controller boundaries include things like: -This keeps rendering code local to the view layer. View helpers can read the state they need where they render instead of relying on large parent-owned state bundles. +- current screen selection +- draft input state +- active filter state +- canonical list data +- one edit session per item -For a fuller Emerge example, see `examples/emerge_lookup_example.md`. +A focused controller has: -## Solve.Lookup With Any GenServer +- one clear responsibility +- one small event surface +- one exposed state map +- one reason to change -Solve.Lookup also works outside Emerge. +For example: ```elixir -defmodule MyApp.CounterWorker do - use GenServer - use Solve.Lookup +defmodule MyApp.Screen do + use Solve.Controller, events: [:set] - def start_link(app) do - GenServer.start_link(__MODULE__, app, name: __MODULE__) - end + @screens [ + %{id: :tasks, label: "Tasks"}, + %{id: :reports, label: "Reports"} + ] @impl true - def init(app), do: {:ok, %{app: app}} - - def render(%{app: app} = state) do - IO.inspect(solve(app, :counter), label: "counter") - state - end + def init(_params, _dependencies), do: %{current: :tasks} - @impl Solve.Lookup - def handle_solve_updated(_updated, state) do - {:ok, render(state)} + def set(screen, state) when screen in [:tasks, :reports] do + %{state | current: screen} end -end -``` - -Use this style when a long-running process wants cached reads and automatic update handling without depending on Emerge. - -For the GenServer-focused example, see `examples/counter_lookup_example.md`. - -## Modeling A Real App - -The [Emerge TodoMVC example](https://github.com/emerge-elixir/emerge/tree/main/example) shows how Solve scales from a single controller into a small state graph. -The controller graph is defined in one place: + def set(_screen, state), do: state -```elixir -def controllers do - [ - controller!(name: :todo_list, module: TodoApp.TodoList), - controller!( - name: :create_todo, - module: TodoApp.CreateTodo, - callbacks: %{ - submit: fn title -> dispatch(:todo_list, :create_todo, title) end - } - ), - controller!( - name: :filter, - module: TodoApp.Filter, - dependencies: [:todo_list] - ), - controller!( - name: :todo_editor, - module: TodoApp.TodoEditor, - variant: :collection, - dependencies: [:todo_list], - callbacks: %{ - save_edit: fn id, title -> dispatch(:todo_list, :update_todo, %{id: id, title: title}) end - }, - collect: fn %{dependencies: %{todo_list: todo_list}} -> - Enum.map(todo_list.ids, fn id -> - {id, [params: %{id: id, title: todo_list.todos[id].title}]} - end) - end - ) - ] + @impl true + def expose(state, _dependencies, _params) do + %{current: state.current, screens: @screens} + end end ``` -That graph separates responsibilities cleanly: - -| Controller | Owns | Depends on | Purpose | -| --- | --- | --- | --- | -| `:todo_list` | canonical todo data | none | create, update, delete, toggle todos | -| `:create_todo` | the draft input value | none | manage input state and submit new todos | -| `:filter` | active filter | `:todo_list` | expose visible ids derived from todo state | -| `{:todo_editor, id}` | local edit state for one todo | `:todo_list` | manage editing UI for a single item | - -This is a core Solve pattern: keep canonical data, derived state, and local UI state in separate controllers with explicit relationships. +## Use dependencies for derived state -## Dependencies, Callbacks, And Collections +Use dependencies when one controller needs another controller's exposed state as +input. -These three features carry most of the architectural weight in a larger Solve app. +Dependencies are for reads. -### Dependencies +They are the right place for: -Use dependencies when one controller needs another controller's exposed state as input. +- filtered ids +- grouped sections +- status summaries +- enabled actions +- derived counts -The filter controller depends on `:todo_list` and computes visible ids in `expose/3`: +For example, a filter controller exposes `visible_ids` instead of making the UI +recompute filtering logic during rendering: ```elixir -defmodule TodoApp.Filter do +defmodule MyApp.Filter do use Solve.Controller, events: [:set] @filters [:all, :active, :completed] @@ -255,262 +271,362 @@ defmodule TodoApp.Filter do def set(_filter, state), do: state @impl true - def expose(state, %{todo_list: todo_list}, _params) do + def expose(state, _dependencies = %{task_list: task_list}, _params) do %{ filters: @filters, active: state.active, - visible_ids: visible_ids(state.active, todo_list) + visible_ids: visible_ids(state.active, task_list) } end defp visible_ids(:all, %{ids: ids}), do: ids - defp visible_ids(:active, %{ids: ids, todos: todos}) do - Enum.reject(ids, fn id -> todos[id].completed? end) + defp visible_ids(:active, %{ids: ids, tasks: tasks}) do + Enum.reject(ids, fn id -> tasks[id].completed? end) end - defp visible_ids(:completed, %{ids: ids, todos: todos}) do - Enum.filter(ids, fn id -> todos[id].completed? end) + defp visible_ids(:completed, %{ids: ids, tasks: tasks}) do + Enum.filter(ids, fn id -> tasks[id].completed? end) end end ``` -This keeps filtering logic out of the UI. The UI asks for visible ids, and the controller decides how they are derived. +This keeps derived state out of the UI layer. -### Callbacks +## Use callbacks for explicit writes -Use callbacks when one controller should trigger another controller's write explicitly. +Use callbacks when one controller needs to request a write from another +controller. -Because a Solve app is an acyclic dependency graph, a controller should not reach back into upstream controllers directly. Dependencies make downstream data flow explicit by declaring what a controller reads from elsewhere in the graph. Callbacks do the same for upstream writes: they make it explicit when a controller needs to request a change from a controller that owns state elsewhere in the graph. +Dependencies describe reads. Callbacks describe writes. -In the TodoMVC demo, `:create_todo` owns the text input state, but `:todo_list` owns the actual todo collection. The app wires those two together with a callback: +This keeps the dependency graph acyclic while preserving ownership. + +For example, an input controller owns the draft title while another controller +owns the canonical list: ```elixir -controller!( - name: :create_todo, - module: TodoApp.CreateTodo, - callbacks: %{ - submit: fn title -> dispatch(:todo_list, :create_todo, title) end - } -) +defmodule MyApp.App do + use Solve + + @impl Solve + def controllers do + [ + controller!(name: :task_list, module: MyApp.TaskList), + controller!( + name: :create_task, + module: MyApp.CreateTask, + callbacks: %{ + submit: fn title -> dispatch(:task_list, :create_task, title) end + } + ) + ] + end +end ``` -The controller remains responsible for its own state transition logic: +The controller still owns its own local transition logic: ```elixir -def submit(_payload, state, _dependencies, callbacks) do - case String.trim(state.title) do - "" -> - %{title: ""} - - title -> - callbacks.submit.(title) - %{title: ""} +defmodule MyApp.CreateTask do + use Solve.Controller, events: [:set_title, :submit] + + @impl true + def init(_params, _dependencies), do: %{title: ""} + + def set_title(title) when is_binary(title) do + %{title: title} + end + + def submit(_payload, state, _dependencies, _callbacks = %{submit: submit}) do + case String.trim(state.title) do + "" -> + %{title: ""} + + title -> + submit.(title) + %{title: ""} + end end end ``` -This makes upstream writes explicit in the same way dependencies make downstream reads explicit, while keeping state ownership clear. +This keeps ownership explicit: -### Collections +- one controller owns the draft input +- another controller owns the canonical data +- the write boundary is visible in the app graph -Use collection sources when you need many similar controllers, one per item. +## Use collections for repeated item state -The TodoMVC demo models per-item editing with a collection source: +Use a collection source when many items need the same local behavior. + +This fits cases like: + +- one edit session per row +- one expanded state per item +- one upload state per file +- one inspector state per node + +A collection source reuses one controller design while keeping each item's local +state separate. ```elixir controller!( - name: :todo_editor, - module: TodoApp.TodoEditor, + name: :task_editor, + module: MyApp.TaskEditor, variant: :collection, - dependencies: [:todo_list], - collect: fn %{dependencies: %{todo_list: todo_list}} -> - Enum.map(todo_list.ids, fn id -> - {id, [params: %{id: id, title: todo_list.todos[id].title}]} + dependencies: [:task_list], + collect: fn _context = %{dependencies: %{task_list: task_list}} -> + Enum.map(task_list.ids, fn id -> + {id, [params: %{id: id, title: task_list.tasks[id].title}]} end) end ) ``` -Each item controller then owns only its local edit behavior: +Each item controller then owns only its own local behavior: ```elixir -defmodule TodoApp.TodoEditor do +defmodule MyApp.TaskEditor do use Solve.Controller, events: [:begin_edit, :cancel_edit, :set_title, :save_edit] @impl true def init(params, _dependencies), do: %{editing?: false, title: params.title} def begin_edit(_payload, state), do: %{state | editing?: true} - def set_title(title, %{editing?: true} = state) when is_binary(title), do: %{state | title: title} - def cancel_edit(_payload, _state, _dependencies, _callbacks, params), do: %{editing?: false, title: params.title} + def set_title(title, %{editing?: true} = state) when is_binary(title) do + %{state | title: title} + end + + def cancel_edit(_payload, _state, _dependencies, _callbacks, params) do + %{editing?: false, title: params.title} + end + + @impl true def expose(state, _dependencies, params), do: Map.put(state, :id, params.id) end ``` -This keeps local item UI state out of the canonical todo list while still making every editor controller addressable as `{:todo_editor, id}`. - -## How UI Code Stays Clean +This keeps per-item local state out of the canonical list while still making +every item controller addressable as `{:task_editor, id}`. -Solve lets UI code stay close to rendering and interaction wiring. +## Read and write state directly -In the TodoMVC demo, view helpers read exactly the state they need with `solve(...)`: +The base API is `Solve.subscribe/3` and `Solve.dispatch/4`. ```elixir -def todo_list() do - filter = solve(TodoApp, :filter) +iex> {:ok, app} = MyApp.App.start_link(name: MyApp.App) +iex> Solve.subscribe(app, :counter) +%{count: 0} - column([], Enum.map(filter.visible_ids, &todo_row/1)) -end +iex> :ok = Solve.dispatch(app, :counter, :increment, %{}) +iex> Solve.subscribe(app, :counter) +%{count: 1} +``` -defp todo_row(todo_id) do - todo_editor = solve(TodoApp, {:todo_editor, todo_id}) +`Solve.subscribe/3`: - if todo_editor.editing? do - editing_row(todo_editor) - else - regular_row(todo_id) - end -end +- returns the current exposed state synchronously +- registers the subscriber for future updates +- works with singleton targets, collected child targets, and collection sources -defp regular_row(todo_id) do - todo = solve(TodoApp, :todo_list).todos[todo_id] +`Solve.dispatch/4`: - row([], [toggle_button(todo), title_button(todo), destroy_button(todo_id)]) -end -``` +- routes an event through the Solve app +- forwards the event to the current controller pid for that target +- becomes a no-op if the target is off or missing -That keeps state access close to the code that renders it. Shared state can still be shared, but it does not have to be threaded through unrelated helpers just because they sit higher in the UI tree. +Solve also exposes a few inspection helpers: -The same applies to event wiring: +- `Solve.controller_pid/2` +- `Solve.controller_events/2` +- `Solve.controller_variant/2` -```elixir -Event.on_change(event(create_todo, :set_title)) -Event.on_press(event(todo_list, :toggle_todo, todo.id)) -Event.on_press(event(filter, :set, filter_name)) -Event.on_blur(event(todo_editor, :save_edit)) -``` +Use these when a test, worker, or tool needs raw access to the runtime. -UI helpers bind directly to the controller that owns the behavior. +## Use Solve.Lookup for process-local access -## End-To-End Flow: Create A Todo +Use `Solve.Lookup` when a long-running process needs: -The create flow shows how several small controllers work together without collapsing into one state owner. +- cached reads +- update handling +- direct event refs -1. The input field reads `:create_todo` and sends `:set_title` and `:submit` events. -2. `:create_todo` owns the draft input value. -3. On submit, `:create_todo` validates the title and calls its `submit` callback. -4. That callback dispatches `:create_todo` to `:todo_list`. -5. `:todo_list` creates the canonical todo. -6. `:filter` recomputes visible ids from the updated todo list. -7. The `:todo_editor` collection is reconciled so a per-item editor exists for the new todo. -8. Any subscribed process rerenders through `Solve.Lookup`. +`Solve.Lookup` is framework-agnostic. It works in ordinary `GenServer` +processes, workers, and UI processes. -This illustrates Solve's style: each controller does one job, dependencies stay explicit, and the state graph grows by adding specialized nodes instead of expanding one central process. +The main helpers are: -## What `solve/2` Returns +- `solve(app, target)` for singleton controllers and collected child targets +- `collection(app, source)` for collection sources +- `events(item)` to read direct event refs +- `event(item, name)` and `event(item, name, payload)` to build direct event + tuples -`solve(app, controller_name)` returns the controller's exposed map augmented with an `:events_` key. +Item lookups return the exposed state map augmented with an `:events_` key. +Collection lookups return `%Solve.Collection{ids, items}` whose items are +augmented item maps. -```elixir -%{ - count: 2, - events_: %{ - increment: {#PID<...>, {:solve_event, :increment}}, - decrement: {#PID<...>, {:solve_event, :decrement}} - } -} -``` +## Use Solve.Lookup in a GenServer -Use `events/1` to read that key safely: +`Solve.Lookup` works well in ordinary `GenServer` processes. ```elixir -counter = solve(app, :counter) -{pid, message} = events(counter)[:increment] -send(pid, message) -``` +defmodule MyApp.CounterWorker do + use GenServer + use Solve.Lookup -If the controller is off, `solve/2` returns `nil` and `events(nil)` also returns `nil`. + def start_link(app) do + GenServer.start_link(__MODULE__, app, name: __MODULE__) + end -For Emerge-style event attrs, prefer `event/2` and `event/3`: + @impl true + def init(app), do: {:ok, %{app: app}} -```elixir -counter = solve(app, :counter) + @impl true + def handle_cast(:increment, state) do + counter = solve(state.app, :counter) -button("+", event(counter, :increment)) -Input.text([Event.on_change(event(counter, :set_title))], counter.title) -button("Reset", event(counter, :set_mode, :all)) + case event(counter, :increment) do + {pid, message} -> send(pid, message) + nil -> :ok + end + + {:noreply, state} + end + + def render(%{app: app} = state) do + IO.inspect(solve(app, :counter), label: "counter") + state + end + + @impl Solve.Lookup + def handle_solve_updated(_updated, state) do + {:ok, render(state)} + end +end ``` -`event/2` returns the same direct `{pid, message}` tuple as `events(counter)[:increment]`, and `event/3` adds a fixed payload to that tuple. +Key properties: -## What `collection/2` Returns +- the first `solve/2` call subscribes the process and populates its local cache +- later `solve/2` calls read from that cache +- `event(counter, :increment)` gives you a direct `{pid, message}` tuple +- `handle_solve_updated/2` handles the process-specific reaction to Solve state + changes -Use `collection(app, source_name)` for collection sources and `solve(app, {source_name, id})` for one collected child. +`use Solve.Lookup` defaults to `handle_info: :auto`. `%Solve.Message{}` +envelopes refresh the local cache and call `handle_solve_updated/2` for you. -```elixir -columns = collection(app, :column) +Use `handle_info: :manual` when the process needs to inspect updates itself and +decide which ones matter. -Enum.map(columns, fn {id, column} -> - {id, column.title, Event.on_change(event(column, :rename))} -end) +## Use Solve.Lookup in Emerge -column = solve(app, {:column, 1}) -{pid, message} = event(column, :rename, "Backlog") -send(pid, message) -``` +`Solve.Lookup` also fits naturally into Emerge viewports. -`collection/2` returns a `%Solve.Collection{}` whose items are the normal lookup item maps: +In Emerge, views read Solve state in `render/0` or `render/1`, bind events from +lookup items, and rerender from `handle_solve_updated/2`. ```elixir -%Solve.Collection{ - ids: [1, 2], - items: %{ - 1 => %{ - id: 1, - title: "Todo", - events_: %{rename: {#PID<...>, {:solve_event, :rename}}} - }, - 2 => %{ - id: 2, - title: "Doing", - events_: %{rename: {#PID<...>, {:solve_event, :rename}}} - } - } -} +defmodule MyApp.Viewport do + use Emerge + use Solve.Lookup + + @impl Viewport + def render(_state) do + counter = solve(MyApp.App, :counter) + + row([], [ + button("+", event(counter, :increment)), + el([], text("Count: #{counter.count}")), + button("-", event(counter, :decrement)) + ]) + end + + @impl Solve.Lookup + def handle_solve_updated(_updated, state) do + {:ok, Viewport.rerender(state)} + end +end ``` -`events/1` returns `nil` for the collection wrapper itself; events live on each item. +This keeps state reads and event wiring close to the view code that uses them. + +## Model larger applications + +As an application grows, one controller stops being enough. The common shape is: + +- one controller for canonical data +- one or more controllers for derived state +- collection controllers for repeated local item behavior + +A task app can be split like this: + +| Controller | Owns | Depends on | Purpose | +| --- | --- | --- | --- | +| `:task_list` | canonical task data | none | create, update, delete, toggle tasks | +| `:create_task` | draft input value | none | manage input state and submit new tasks | +| `:filter` | active filter | `:task_list` | expose visible ids derived from task state | +| `{:task_editor, id}` | local edit state for one task | `:task_list` | manage editing UI for a single item | + +This is a common Solve structure: + +- canonical data in one controller +- derived state in another +- local per-item state in a collection source + +Split into separate apps only when parts of your system become genuinely +independent domains. That means they: + +- have their own controller graph +- evolve independently +- do not share much internal state ownership +- are composed together at a higher level rather than tightly coordinated + +Separate apps also fit when you need different variants of the same graph. A +user-facing app and an admin app can reuse the same core controllers while the +admin app adds moderation, audit, or other admin-only controllers. + +Controllers stay reusable because they do not know which app they live in. The +app defines how they are wired together. This works when reused controllers +still receive the dependency keys, params, and callbacks they expect. + +One concrete example of this style is the Emerge TodoMVC demo: +[https://github.com/emerge-elixir/emerge/tree/main/example](https://github.com/emerge-elixir/emerge/tree/main/example) + +## Pick the right primitive -## When To Reach For Which Tool +Use these rules when choosing between Solve primitives: - Use a singleton controller for one focused state owner. - Use a dependency when one controller derives state from another. -- Use a callback when one controller should trigger another controller's write explicitly. +- Use a callback when one controller requests a write from another. - Use a collection source when each item needs its own local behavior or state. -- Use `Solve.Lookup` when a process wants local cached reads and update-aware event wiring. +- Use `Solve.Lookup` when a process needs cached reads and update-aware event + wiring. -## Key Rules +## Respect the invariants -- Running controller instances must expose plain maps. -- Collection sources expose `%Solve.Collection{ids, items}` through `Solve.subscribe/3` and `Solve.Lookup.collection/2`. +- Running controller instances expose plain maps. +- Collection sources expose `%Solve.Collection{ids, items}` through + `Solve.subscribe/3` and `Solve.Lookup.collection/2`. - `nil` means a singleton or collected child is off or stopped. - `:events_` is reserved in exposed maps for lookup augmentation. -- `Solve.subscribe/3` returns raw exposed state for both singleton targets and collection sources. +- `Solve.subscribe/3` returns raw exposed state for singleton targets and + collection sources. - `Solve.Lookup.solve/2` returns augmented singleton or collected-child views. - `Solve.Lookup.collection/2` returns an augmented collection view. -## Further Reading +## Keep reading -- `examples/counter_lookup_example.md` shows `Solve.Lookup` in an ordinary `GenServer`, including manual `handle_info`. -- `examples/emerge_lookup_example.md` shows the render-driven `Solve.Lookup` flow with Emerge. - `ARCHITECTURE.md` covers the runtime model and lifecycle rules in more detail. -- [Emerge TodoMVC example](https://github.com/emerge-elixir/emerge/tree/main/example) is the full Emerge + Solve application. -## Attribution +## Acknowledge the influences Solve draws significant conceptual inspiration from -[Keechma Next](https://github.com/keechma/keechma-next/), especially in its emphasis on -controller-oriented state management, explicit data flow, and keeping UI structure separate from -state structure. +[Keechma Next](https://github.com/keechma/keechma-next/), especially in its +emphasis on controller-oriented state management, explicit data flow, and +keeping UI structure separate from state structure. diff --git a/examples/counter_lookup_example.md b/examples/counter_lookup_example.md deleted file mode 100644 index 1c1bc76..0000000 --- a/examples/counter_lookup_example.md +++ /dev/null @@ -1,162 +0,0 @@ -# Counter Lookup Example - -This example shows `Solve.Lookup` from an ordinary `GenServer`. - -Use this pattern when a long-running process wants process-local cached reads, direct event refs, -and update handling without depending on Emerge. - -- one singleton controller -- one `Solve` app -- one plain `GenServer` using `Solve.Lookup` - -For the main overview, see `README.md`. If you are rendering with Emerge, see -`examples/emerge_lookup_example.md`. - -## Controller And Solve App - -```elixir -defmodule MyApp.CounterController do - use Solve.Controller, events: [:increment, :decrement] - - @impl true - def init(_params, _dependencies), do: %{count: 0} - - def increment(_payload, state), do: %{state | count: state.count + 1} - def decrement(_payload, state), do: %{state | count: state.count - 1} -end - -defmodule MyApp.State do - use Solve - - @impl true - def controllers do - [controller!(name: :counter, module: MyApp.CounterController)] - end -end -``` - -## Auto `Solve.Lookup` In A `GenServer` - -```elixir -defmodule MyApp.CounterWorker do - use GenServer - use Solve.Lookup - - def start_link(app) do - GenServer.start_link(__MODULE__, app, name: __MODULE__) - end - - @impl true - def init(app), do: {:ok, %{app: app}} - - @impl true - def handle_cast(:increment, state) do - counter = solve(state.app, :counter) - - case event(counter, :increment) do - {pid, message} -> send(pid, message) - nil -> :ok - end - - {:noreply, state} - end - - def render(%{app: app} = state) do - IO.inspect(solve(app, :counter), label: "counter") - state - end - - @impl Solve.Lookup - def handle_solve_updated(_updated, state) do - {:ok, render(state)} - end -end -``` - -What this pattern provides: - -- the first `solve/2` call subscribes the worker and populates its local cache -- later `solve/2` calls read from that cache -- `event(counter, :increment)` gives you a direct `{pid, message}` tuple you can send immediately -- `handle_solve_updated/2` handles only the process-specific reaction to Solve state changes - -`use Solve.Lookup` defaults to `handle_info: :auto`, so `%Solve.Message{}` update envelopes refresh -the local cache and call `handle_solve_updated/2` for you. - -## Manual `handle_info` - -Use `handle_info: :manual` when you want explicit control over which Solve updates trigger work. - -```elixir -defmodule MyApp.ManualCounterWorker do - use GenServer - use Solve.Lookup, handle_info: :manual - - @impl true - def init(app), do: {:ok, %{app: app}} - - def handle_info(nil, state) do - {:noreply, state} - end - - def handle_info(%Solve.Message{} = message, %{app: app} = state) do - case handle_message(message) do - %{^app => %Solve.Lookup.Updated{refs: refs}} -> - if :counter in refs, - do: {:noreply, render(state)}, - else: {:noreply, state} - - %{} -> - {:noreply, state} - end - end - - def handle_info(_message, state) do - {:noreply, state} - end - - def render(%{app: app} = state) do - IO.inspect(solve(app, :counter), label: "counter") - state - end -end -``` - -Choose this variant when the process wants to inspect `Solve.Lookup.handle_message/1` itself and -decide which updates matter. - -## What `solve/2` Returns - -`solve(app, :counter)` returns the controller's exposed map augmented with an `:events_` key. - -```elixir -%{ - count: 1, - events_: %{ - increment: {#PID<...>, {:solve_event, :increment}}, - decrement: {#PID<...>, {:solve_event, :decrement}} - } -} -``` - -Use `events/1` when you want to read those refs directly: - -```elixir -counter = solve(app, :counter) -{pid, message} = events(counter)[:increment] -send(pid, message) -``` - -`event(counter, :increment)` returns the same tuple as `events(counter)[:increment]`. - -If the controller is off, `solve/2` returns `nil`, `events(nil)` returns `nil`, and -`event(nil, :increment)` also returns `nil`. - -## When To Use This Pattern - -Use this style when: - -- a `GenServer` or worker process wants cached reads from Solve -- the process should react to updates over time -- you want direct event refs without a render loop -- you want the option to drop into manual `handle_info` control diff --git a/examples/emerge_lookup_example.md b/examples/emerge_lookup_example.md deleted file mode 100644 index ac533ce..0000000 --- a/examples/emerge_lookup_example.md +++ /dev/null @@ -1,138 +0,0 @@ -# Emerge Lookup Example - -This example shows the standard `Solve.Lookup` style for Emerge. - -- read state in `render/1` -- build UI handlers with `event/2` and `event/3` -- rerender from `handle_solve_updated/2` -- use `use Solve.Lookup, :helpers` in helper modules that only need lookup helpers - -It follows the same overall shape as the -[Emerge TodoMVC example](https://github.com/emerge-elixir/emerge/tree/main/example). - -## Singleton Lookup In `render/1` - -```elixir -defmodule EmergeDemo do - use Emerge - use Solve.Lookup - - @impl Viewport - def render(_state) do - counter = solve(EmergeDemo.State, :counter) - - column([], [ - row([], [ - button("+", event(counter, :increment)), - el([], text("Count: #{counter.count}")), - button("-", event(counter, :decrement)) - ]) - ]) - end - - @impl Solve.Lookup - def handle_solve_updated(_updated, state) do - {:ok, Viewport.rerender(state)} - end -end -``` - -What this pattern provides: - -- the first `solve/2` call subscribes the viewport process -- later `solve/2` calls read from the local lookup cache -- `event(counter, :increment)` gives you an Emerge-ready `{pid, message}` tuple -- `handle_solve_updated/2` can remain a simple rerender hook - -Use `event(controller, event_name)` when Emerge should provide the payload later, like -`Event.on_change(event(form, :set_title))`. Use `event(controller, event_name, payload)` when the -payload is fixed at render time. - -## Lookup Helpers In View Modules - -When a helper module only needs the lookup imports and should not get auto `handle_info/2` -behavior or a `handle_solve_updated/2` requirement, use `use Solve.Lookup, :helpers`. - -```elixir -defmodule MyApp.View.TodoApp do - use Emerge.UI - use Solve.Lookup, :helpers - - def todo_list() do - filter = solve(MyApp.State, :filter) - - column([], Enum.map(filter.visible_ids, &todo_row/1)) - end -end -``` - -This keeps state access close to the code that renders it. Helper modules can read the controller -state they need directly instead of depending on large parent-owned state bundles. - -## Collection Lookup Uses The Same Pattern - -If the state module exposes a collection source, read it directly in `render/1`. - -```elixir -def render(_state) do - columns = collection(MyApp.State, :column) - - row([], Enum.map(columns, fn {_id, column} -> - Input.text([Event.on_change(event(column, :rename))], column.title) - end)) -end -``` - -`collection/2` returns `%Solve.Collection{ids, items}`, but because it implements `Enumerable`, UI -code can iterate it directly. - -If you already know the id of one item, read it directly with `solve/2`: - -```elixir -column = solve(MyApp.State, {:column, 1}) -Input.text([Event.on_change(event(column, :rename))], column.title) -``` - -Important rules: - -- `events(collection(...))` returns `nil` -- events live on the items inside the collection -- `event/2` and `event/3` only work on lookup item maps, not the collection wrapper -- `solve(app, :column)` is for singletons only; use `collection(app, :column)` for collection sources - -## Collection Source Definition - -On the Solve side, this is still just a controller spec. - -```elixir -defmodule MyApp.State do - use Solve - - @impl true - def controllers do - [ - controller!( - name: :column, - module: MyApp.ColumnController, - variant: :collection, - collect: fn %{app_params: %{columns: columns}} -> - Enum.map(columns, fn %{id: id, title: title} -> - {id, [params: %{id: id, title: title}]} - end) - end - ) - ] - end -end -``` - -`collect/1` returns ordered `{id, opts}` tuples. Solve diffs those ids, manages child controllers -like `{:column, 1}`, and exposes the source as `%Solve.Collection{}`. - -## When To Use The GenServer Example Instead - -Use `examples/counter_lookup_example.md` when you are not using Emerge and want: - -- a plain `GenServer` example -- a process-centric update loop -- manual `handle_info: :manual` control outside a render loop diff --git a/mix.exs b/mix.exs index df56318..fe2700b 100644 --- a/mix.exs +++ b/mix.exs @@ -43,12 +43,9 @@ defmodule Solve.MixProject do source_ref: "readme", extras: [ "README.md", - "ARCHITECTURE.md", - "examples/emerge_lookup_example.md", - "examples/counter_lookup_example.md" + "ARCHITECTURE.md" ], groups_for_extras: [ - Guides: ["examples/emerge_lookup_example.md", "examples/counter_lookup_example.md"], Internals: ["ARCHITECTURE.md"] ], groups_for_modules: [ From 861bf1cfec33ac2d201241ce23e664e151056f26 Mon Sep 17 00:00:00 2001 From: Damirados Date: Wed, 8 Apr 2026 14:23:02 +0200 Subject: [PATCH 2/4] Tighten architecture guide wording Clarify runtime terminology and drop references to public examples that now live in the README. --- ARCHITECTURE.md | 76 ++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 39 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 215c3cc..fb48a48 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,15 +1,15 @@ -# Solve Architecture +# Solve architecture Solve is a controller-graph runtime built from one coordinating `Solve` process, a set of controller `GenServer`s, and optional collection sources that materialize ordered child sets. The `Solve` app process owns graph validation, controller lifecycle, dependency reconciliation, -source and target exposed-state caching, and external subscriber tracking. Concrete controller -instances own their internal state and expose plain-map public views derived from `expose/3`. +source- and target-level exposed-state caching, and external subscriber tracking. Controller +instances own their internal state and expose plain-map public views through `expose/3`. -## Core Model +## Core model -### Sources And Targets +### Sources and targets Solve distinguishes between static source names and concrete runtime targets. @@ -19,9 +19,9 @@ Solve distinguishes between static source names and concrete runtime targets. - a collection source itself is virtual; it does not own a controller pid The dependency graph is static and source-level. Runtime lifecycle, subscriptions, and dispatch can -address either source names or concrete targets. +operate on either source names or concrete targets. -### Solve App Runtime +### Solve app runtime Each `use Solve` module starts a single app `GenServer` that: @@ -32,7 +32,7 @@ Each `use Solve` module starts a single app `GenServer` that: - tracks external subscribers per source or target - reconciles dependents when upstream state, params, or collection membership change -### Controller Graph +### Controller graph The app module defines `controllers/0` with `controller!/1` specs. Each spec declares: @@ -47,7 +47,7 @@ The app module defines `controllers/0` with `controller!/1` specs. Each spec dec Inside a `use Solve` module, callback functions can call bare `dispatch/2` or `dispatch/3`. That implicit app resolution is only guaranteed while a controller event handler is executing. -Dependency bindings are normalized into source-level graph edges plus local dependency keys. +Solve normalizes dependency bindings into source-level graph edges plus local dependency keys. Examples: @@ -73,7 +73,7 @@ Collection sources are different: Solve materializes them as `%Solve.Collection{ the exposed state of their child targets. The child controllers themselves are ordinary controllers and do not know they came from a collection source. -### Exposed State +### Exposed state Solve treats exposed state as the shared boundary between processes. @@ -100,7 +100,7 @@ This lets the same controller broadcast turn into: - a `:replace` dependency patch for single bindings - `:collection_put`, `:collection_delete`, or `:collection_reorder` patches for collection bindings -## Graph Compilation +## Graph compilation Graph validation happens on app boot, before any controllers start. @@ -123,11 +123,11 @@ The compiled graph produces: This gives Solve a stable source-level dependency order plus fast direct-dependent lookup. -## Controller Lifecycle +## Controller lifecycle On boot, Solve walks the source graph in topological order and reconciles each source. -### Singleton Sources +### Singleton sources For a singleton source, the runtime: @@ -147,7 +147,7 @@ Params control existence: Replacement is start-new-then-stop-old. The new controller is registered before the old one is shut down, which avoids a gap in availability. -### Collection Sources +### Collection sources For a collection source, the runtime: @@ -162,9 +162,9 @@ For a collection source, the runtime: Collected child replacement is params-based for a given `id`. If only collected callbacks change, Solve keeps the existing child pid and updates its callbacks in place. -## Dependency Propagation +## Dependency propagation -### Direct Encoded Subscriptions +### Direct encoded subscriptions Controllers subscribe directly to their upstream dependencies when they start. @@ -176,8 +176,8 @@ Binding kinds matter: - a filtered collection binding stores a `%Solve.Collection{}` and subscribes only to child targets whose current `{id, item}` match the filter -Controllers still broadcast directly. The difference is that subscribers now carry encoder -functions, so a broadcast can be transformed before delivery. +Controllers still broadcast directly. Subscribers now carry encoder functions, so a broadcast can +be transformed before delivery. Examples: @@ -185,7 +185,7 @@ Examples: - collection binding encoder -> `%Solve.DependencyUpdate{op: :collection_put, ...}` - filtered collection binding encoder -> either `:collection_put` or `:collection_delete` -### Solve App Responsibilities +### Solve app responsibilities The Solve app also subscribes to every running singleton and collected child. That lets it: @@ -194,10 +194,9 @@ The Solve app also subscribes to every running singleton and collected child. Th - decide whether direct dependents should start, stop, stay running, or be replaced - add or remove dependency subscriptions when collection membership or filters change -This split keeps state propagation direct while still letting the app process stay in control of -lifecycle decisions. +This keeps state propagation direct while leaving lifecycle decisions with the app process. -## External Interaction APIs +## External interaction APIs ### `Solve.subscribe/3` @@ -222,7 +221,7 @@ current controller pid for that target. - if the target is running, the event is forwarded to it - if the target is stopped, unknown, or a collection source atom, dispatch is a silent no-op -### Introspection Helpers +### Introspection helpers Solve also exposes: @@ -233,13 +232,13 @@ Solve also exposes: ## Solve.Lookup -`Solve.Lookup` is a process-local facade over `Solve.subscribe/3` and `Solve.dispatch/4`. +`Solve.Lookup` is a process-local wrapper and cache around `Solve.subscribe/3` +and `Solve.dispatch/4`. -In practice, the most common usage is render-driven UI code. See -`examples/emerge_lookup_example.md` for that style and `examples/counter_lookup_example.md` for the -smaller non-UI variant. +The README covers the most common public usage patterns, including UI code and +ordinary long-running processes. -It caches three shapes: +It stores three lookup shapes: - singleton item lookups via `solve(app, :counter)` - collected child item lookups via `solve(app, {:column, 1})` @@ -249,10 +248,10 @@ Item lookups are augmented with `:events_` direct event tuples. Collection looku `%Solve.Collection{}` whose items are augmented item maps. The collection wrapper itself has no events. -`handle_message/1` refreshes the process-local cache and returns updates grouped by app as +`handle_message/1` refreshes the local cache and returns updates grouped by app as `%Solve.Lookup.Updated{refs, collections}`. -### Auto Mode +### Auto mode `use Solve.Lookup` defaults to `handle_info: :auto`. @@ -263,13 +262,13 @@ Injected `handle_info/2` clauses: - refresh the local cache through `handle_message/1` - call `handle_solve_updated/2` with `%Solve.Lookup.Updated{refs, collections}` -### Manual Mode +### Manual mode With `handle_info: :manual`, no `handle_info/2` clauses are injected. The caller matches `%Solve.Message{}` itself, calls `handle_message/1`, and decides what to do with the returned map of updated refs and collections. -## Message Shapes +## Message shapes Singleton or child updates use `%Solve.Update{}`: @@ -324,7 +323,7 @@ The runtime depends on a few fixed rules: - dispatch to unknown or stopped targets is a no-op - undeclared controller events are logged and discarded -## Typical Flows +## Typical flows ### Boot @@ -333,7 +332,7 @@ The runtime depends on a few fixed rules: 3. Running targets subscribe to their dependency targets. 4. Solve subscribes to each running target and caches both target and source exposed state. -### Event Dispatch +### Event dispatch 1. A process either calls `Solve.dispatch/4`, sends a deferred dispatch envelope, or sends a direct `{pid, {:solve_event, ...}}` tuple produced by `Solve.Lookup`. @@ -341,14 +340,14 @@ The runtime depends on a few fixed rules: 3. The controller updates internal state and recomputes `expose/3`. 4. If the exposed map changed, the controller broadcasts an update envelope. -### Upstream State Change +### Upstream state change 1. An upstream singleton or collected child broadcasts a new exposed map. 2. Dependent controllers receive the encoded dependency update directly. 3. The Solve app refreshes its target cache and, if needed, its source `%Solve.Collection{}`. 4. Solve reconciles direct dependents to decide whether to keep, stop, start, replace, attach, or detach subscriptions. -### Collection Reconcile +### Collection reconcile 1. A collection source re-runs `collect/1` because its upstream state changed. 2. Solve diffs ordered ids against the existing materialized collection. @@ -356,7 +355,7 @@ The runtime depends on a few fixed rules: 4. Solve rebuilds the source `%Solve.Collection{}` and notifies external collection subscribers. 5. Solve reevaluates collection bindings in dependents and adds or removes child subscriptions. -### Crash And Restart +### Crash and restart 1. A controller target exits unexpectedly. 2. Solve marks that target stopped and notifies external subscribers with an update carrying `nil`. @@ -365,5 +364,4 @@ The runtime depends on a few fixed rules: 5. Solve attempts restart within a bounded retry budget. 6. If the restart budget is exhausted, the Solve app stops. -For public usage examples, see `README.md`, `examples/emerge_lookup_example.md`, and -`examples/counter_lookup_example.md`. +For public usage examples, see `README.md`. From 63f7f601f5af2d209618d335e0078c35b5ca9b82 Mon Sep 17 00:00:00 2001 From: Damirados Date: Wed, 8 Apr 2026 15:12:37 +0200 Subject: [PATCH 3/4] Add CI and Hex release workflows Automate local and GitHub checks with a shared ci-tests.sh entrypoint and publish Hex releases from version tags. Add the package metadata and small Credo/Dialyzer fixes needed for the release pipeline to pass cleanly. --- .credo.exs | 21 ++++++++++ .formatter.exs | 2 +- .github/workflows/ci.yml | 44 ++++++++++++++++++++ .github/workflows/hex_publish.yml | 69 +++++++++++++++++++++++++++++++ LICENSE | 21 ++++++++++ ci-tests.sh | 60 +++++++++++++++++++++++++++ lib/solve/dependency_graph.ex | 15 ++----- lib/solve/lookup.ex | 7 ++-- mix.exs | 65 +++++++++++++++++++++++++---- mix.lock | 6 +++ 10 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 .credo.exs create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/hex_publish.yml create mode 100644 LICENSE create mode 100755 ci-tests.sh diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..e62d8f1 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,21 @@ +%{ + configs: [ + %{ + name: "default", + files: %{ + included: ["{mix,.formatter,.credo}.exs", "lib/**/*.ex", "test/**/*.exs"], + excluded: [~r"/_build/", ~r"/deps/"] + }, + strict: true, + checks: [ + {Credo.Check.Refactor.Apply, false}, + {Credo.Check.Refactor.CondStatements, false}, + {Credo.Check.Refactor.CyclomaticComplexity, false}, + {Credo.Check.Refactor.FilterFilter, false}, + {Credo.Check.Refactor.NegatedConditionsWithElse, false}, + {Credo.Check.Refactor.Nesting, false}, + {Credo.Check.Refactor.RedundantWithClauseResult, false} + ] + } + ] +} diff --git a/.formatter.exs b/.formatter.exs index d2cda26..5a0d2db 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,4 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e41fcd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + ci: + name: Elixir CI + runs-on: ubuntu-24.04 + + steps: + - name: Checkout source code + uses: actions/checkout@v5 + + - name: Set up Elixir and OTP + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.19.5" + otp-version: "28.3" + + - name: Cache Mix dependencies and build + uses: actions/cache@v5 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - name: Install Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + + - name: Install dependencies + run: mix deps.get + + - name: Run CI test script + run: ./ci-tests.sh diff --git a/.github/workflows/hex_publish.yml b/.github/workflows/hex_publish.yml new file mode 100644 index 0000000..33d9976 --- /dev/null +++ b/.github/workflows/hex_publish.yml @@ -0,0 +1,69 @@ +name: Hex Publish + +on: + push: + tags: + - "v*" + +jobs: + publish: + name: Publish Hex package + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + + steps: + - name: Checkout source code + uses: actions/checkout@v5 + + - name: Set up Elixir and OTP + uses: erlef/setup-beam@v1 + with: + elixir-version: "1.19.5" + otp-version: "28.3" + + - name: Cache Mix dependencies and build + uses: actions/cache@v5 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix- + + - name: Install Hex and Rebar + run: | + mix local.hex --force + mix local.rebar --force + + - name: Install dependencies + run: mix deps.get + + - name: Verify tag matches mix version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + project_version="$(mix run -e 'IO.write(Mix.Project.config()[:version])')" + + if [ "$tag_version" != "$project_version" ]; then + echo "Tag version $tag_version does not match mix version $project_version" >&2 + exit 1 + fi + + - name: Run CI test script + run: ./ci-tests.sh + + - name: Build docs + run: mix docs + + - name: Verify Hex package contents + run: mix hex.build --unpack + + - name: Publish package + run: mix hex.publish --yes + + - name: Publish docs + run: mix hex.publish docs --yes diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4349d11 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Solve contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ci-tests.sh b/ci-tests.sh new file mode 100755 index 0000000..e61c4b0 --- /dev/null +++ b/ci-tests.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-all}" + +run_quality() { + mix format --check-formatted + mix compile --warnings-as-errors + mix credo --strict +} + +run_tests() { + mix test +} + +run_dialyzer() { + local output_file + output_file="$(mktemp)" + + if mix dialyzer >"${output_file}" 2>&1; then + cat "${output_file}" + rm -f "${output_file}" + return 0 + fi + + cat "${output_file}" + + if grep -q "File not found:" "${output_file}"; then + echo "Detected a stale Dialyzer PLT; rebuilding local PLTs and retrying..." >&2 + rm -f _build/dev/dialyxir_*.plt _build/dev/dialyxir_*.plt.hash + rm -f "${output_file}" + mix dialyzer + return 0 + fi + + rm -f "${output_file}" + return 1 +} + +case "$mode" in + quality) + run_quality + ;; + test) + run_tests + ;; + dialyzer) + run_dialyzer + ;; + all) + run_quality + run_tests + run_dialyzer + ;; + *) + echo "usage: ./ci-tests.sh [quality|test|dialyzer|all]" >&2 + exit 1 + ;; +esac diff --git a/lib/solve/dependency_graph.ex b/lib/solve/dependency_graph.ex index 949fc49..453279b 100644 --- a/lib/solve/dependency_graph.ex +++ b/lib/solve/dependency_graph.ex @@ -207,13 +207,7 @@ defmodule Solve.DependencyGraph do controller_specs_by_name |> Map.keys() |> Enum.find_value([], fn controller_name -> - case do_find_cycle( - controller_name, - controller_specs_by_name, - MapSet.new(), - [], - MapSet.new() - ) do + case do_find_cycle(controller_name, controller_specs_by_name, MapSet.new(), []) do {:cycle, cycle, _visited} -> cycle {:ok, _visited} -> nil end @@ -341,9 +335,9 @@ defmodule Solve.DependencyGraph do process_queue(new_queue, dependents_map, updated_in_degrees, all_nodes, [node | result]) end - defp do_find_cycle(node, controller_specs_by_name, visited, stack, stack_set) do + defp do_find_cycle(node, controller_specs_by_name, visited, stack) do cond do - MapSet.member?(stack_set, node) -> + node in stack -> {:cycle, cycle_from_stack(node, stack), visited} MapSet.member?(visited, node) -> @@ -352,11 +346,10 @@ defmodule Solve.DependencyGraph do true -> visited = MapSet.put(visited, node) stack = [node | stack] - stack_set = MapSet.put(stack_set, node) dependencies = controller_specs_by_name |> Map.fetch!(node) |> Map.get(:dependencies, []) Enum.reduce_while(dependencies, {:ok, visited}, fn dependency, {:ok, acc_visited} -> - case do_find_cycle(dependency, controller_specs_by_name, acc_visited, stack, stack_set) do + case do_find_cycle(dependency, controller_specs_by_name, acc_visited, stack) do {:cycle, cycle, new_visited} -> {:halt, {:cycle, cycle, new_visited}} {:ok, new_visited} -> {:cont, {:ok, new_visited}} end diff --git a/lib/solve/lookup.ex b/lib/solve/lookup.ex index 1fa0885..74d4b15 100644 --- a/lib/solve/lookup.ex +++ b/lib/solve/lookup.ex @@ -15,14 +15,15 @@ defmodule Solve.Lookup do @enforce_keys [:refs, :collections] defstruct refs: [], collections: [] + + @type t :: %__MODULE__{refs: [Solve.controller_target()], collections: [atom()]} end @events_key :events_ @type target :: Solve.controller_target() - @type updated_controllers :: %{optional(GenServer.server()) => Updated.t()} - @callback handle_solve_updated(updated_controllers(), term()) :: {:ok, term()} + @callback handle_solve_updated(map(), term()) :: {:ok, term()} @optional_callbacks handle_solve_updated: 2 defmacro __using__(opts \\ []) do @@ -168,7 +169,7 @@ defmodule Solve.Lookup do def events(%{@events_key => events}), do: events def events(_value), do: nil - @spec handle_message(Solve.Message.t()) :: updated_controllers() + @spec handle_message(Solve.Message.t()) :: map() def handle_message(%Solve.Message{type: :dispatch, payload: %Solve.Dispatch{} = dispatch}) do app = resolve_app!(dispatch.app) Solve.dispatch(app, dispatch.controller_name, dispatch.event, dispatch.payload) diff --git a/mix.exs b/mix.exs index fe2700b..0c01e75 100644 --- a/mix.exs +++ b/mix.exs @@ -1,6 +1,7 @@ defmodule Solve.MixProject do use Mix.Project + @description "Declarative UI agnostic state management architecture" @version "0.1.0" @source_url "https://github.com/emerge-elixir/solve" @@ -11,10 +12,13 @@ defmodule Solve.MixProject do elixir: "~> 1.18", name: "Solve", start_permanent: Mix.env() == :prod, - description: description(), + description: @description, + package: package(), source_url: @source_url, homepage_url: @source_url, docs: docs(), + aliases: aliases(), + dialyzer: [plt_add_apps: [:mix]], deps: deps() ] end @@ -26,21 +30,68 @@ defmodule Solve.MixProject do ] end + def cli do + [ + preferred_envs: preferred_cli_env() + ] + end + # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true} + {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev], runtime: false} + ] + end + + defp aliases do + [ + quality: [ + "format --check-formatted", + "compile --warnings-as-errors", + "credo --strict", + "dialyzer" + ], + "quality.fast": [ + "format --check-formatted", + "compile --warnings-as-errors", + "credo --strict" + ] + ] + end + + defp preferred_cli_env do + [ + credo: :test, + dialyzer: :dev, + quality: :test, + "quality.fast": :test ] end - defp description do - "Declarative UI agnostic state management architecture" + defp package do + [ + licenses: ["MIT"], + links: %{ + "GitHub" => @source_url, + "Issues" => @source_url <> "/issues" + }, + files: [ + "lib", + "README.md", + "ARCHITECTURE.md", + "LICENSE", + "mix.exs", + "mix.lock" + ] + ] end defp docs do [ main: "readme", - source_ref: "readme", + source_ref: "v#{@version}", extras: [ "README.md", "ARCHITECTURE.md" @@ -50,8 +101,8 @@ defmodule Solve.MixProject do ], groups_for_modules: [ {"Core", [Solve]}, - {"Controllers", [Solve.Controller, Solve.ControllerAssign]}, - {"Phoenix integration", [Solve.LiveView, Solve.LiveComponent]}, + {"Controllers", [Solve.Controller, Solve.ControllerSpec, Solve.Collection]}, + {"Messaging", [Solve.Lookup, Solve.Dispatch, Solve.Message, Solve.Update]}, {"Internals", [Solve.DependencyGraph]} ], nest_modules_by_prefix: [Solve] diff --git a/mix.lock b/mix.lock index fecad0c..ed2ef8a 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,12 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, From 2e8418cd2f63ea1be38a33f2a41af491dde5f0a8 Mon Sep 17 00:00:00 2001 From: Damirados Date: Wed, 8 Apr 2026 15:14:11 +0200 Subject: [PATCH 4/4] Add README badges Surface the package, docs, CI, and license status from the project homepage so the release and workflow metadata are visible immediately. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index fc74e91..b99fcba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Solve +[![Hex.pm](https://img.shields.io/hexpm/v/solve.svg)](https://hex.pm/packages/solve) +[![HexDocs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/solve) +[![CI](https://img.shields.io/badge/CI-GitHub_Actions-2088FF?logo=githubactions&logoColor=white)](https://github.com/emerge-elixir/solve/actions/workflows/ci.yml) +[![License](https://img.shields.io/github/license/emerge-elixir/solve.svg)](https://github.com/emerge-elixir/solve/blob/main/LICENSE) + Solve is a controller-graph state runtime for Elixir applications. It models application state as a graph of focused controllers instead of a tree