From eb2e9c8a2958810f278f5b5ab5226f6efe19ebef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Dro=C5=BCd=C5=BCy=C5=84ski?= Date: Sun, 6 Sep 2026 16:36:19 +0200 Subject: [PATCH 1/3] Dispatch store callbacks through `Storex.Store` helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Storex.Store.__before_compile__/1` generated a `case` over the return value of each store's `init/2` callback. Because `@store` is a concrete module at expansion time, Elixir's type checker (1.18+) proves the unused clauses dead and warns once per store — attributed to the downstream author's own file, where it cannot be fixed. Projects building with `mix compile --warnings-as-errors` could not compile against storex on Elixir 1.18 or newer. Move the branching into `Storex.Store.__init__/3`, `__mutation__/6` and `__terminate__/4`. These dispatch with `apply/3` and are compiled once against the full callback contract rather than specialised per store, so every clause stays reachable and no warning is emitted. The public callback contract and runtime behaviour are unchanged: an unsupported `init/2` return still raises, an unsupported `mutation/5` return still resolves to an error tuple, and an unmatched mutation name is still reported as an error. The `mutation/5` dispatch was already immune because it used `Kernel.apply/3`; routing it through the same helper keeps that property from being lost to a future cleanup. CI gains Elixir 1.18, 1.19 and 1.20 entries and a `mix compile --warnings-as-errors` step so this cannot regress. --- .github/workflows/main.yml | 36 +++++--- lib/storex/store.ex | 111 ++++++++++++++--------- test/fixtures/stores/invalid_init.ex | 11 +++ test/fixtures/stores/invalid_mutation.ex | 15 +++ test/storex/store_test.exs | 103 +++++++++++++++++++++ 5 files changed, 219 insertions(+), 57 deletions(-) create mode 100644 test/fixtures/stores/invalid_init.ex create mode 100644 test/fixtures/stores/invalid_mutation.ex create mode 100644 test/storex/store_test.exs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6576153..63a41cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ on: jobs: test: - runs-on: ubuntu-22.04 + runs-on: ${{matrix.version.os}} env: MIX_ENV: test name: Elixir ${{matrix.version.elixir}} / OTP ${{matrix.version.otp}} @@ -18,20 +18,29 @@ jobs: matrix: version: [ - { elixir: "1.14", otp: "24" }, - { elixir: "1.14", otp: "25" }, + { elixir: "1.14", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.14", otp: "25", os: "ubuntu-22.04" }, - { elixir: "1.15", otp: "24" }, - { elixir: "1.15", otp: "25" }, - { elixir: "1.15", otp: "26" }, + { elixir: "1.15", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.15", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.15", otp: "26", os: "ubuntu-22.04" }, - { elixir: "1.16", otp: "24" }, - { elixir: "1.16", otp: "25" }, - { elixir: "1.16", otp: "26" }, + { elixir: "1.16", otp: "24", os: "ubuntu-22.04" }, + { elixir: "1.16", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.16", otp: "26", os: "ubuntu-22.04" }, - { elixir: "1.17", otp: "25" }, - { elixir: "1.17", otp: "26" }, - { elixir: "1.17", otp: "27" }, + { elixir: "1.17", otp: "25", os: "ubuntu-22.04" }, + { elixir: "1.17", otp: "26", os: "ubuntu-22.04" }, + { elixir: "1.17", otp: "27", os: "ubuntu-22.04" }, + + { elixir: "1.18", otp: "26", os: "ubuntu-24.04" }, + { elixir: "1.18", otp: "27", os: "ubuntu-24.04" }, + + { elixir: "1.19", otp: "27", os: "ubuntu-24.04" }, + { elixir: "1.19", otp: "28", os: "ubuntu-24.04" }, + + { elixir: "1.20", otp: "27", os: "ubuntu-24.04" }, + { elixir: "1.20", otp: "28", os: "ubuntu-24.04" }, ] steps: - uses: actions/checkout@v4 @@ -54,5 +63,8 @@ jobs: mix local.rebar --force mix local.hex --force mix deps.get + mix deps.compile + - name: Compile + run: mix compile --warnings-as-errors - name: Run Tests run: mix test diff --git a/lib/storex/store.ex b/lib/storex/store.ex index de5f8da..4cb765d 100644 --- a/lib/storex/store.ex +++ b/lib/storex/store.ex @@ -23,6 +23,56 @@ defmodule Storex.Store do @callback terminate(session_id :: binary(), params :: %{binary() => any()}, state :: any()) :: any() @optional_callbacks terminate: 3 + @doc false + def __init__(store, session, params) do + apply(store, :init, [session, params]) + |> case do + {:ok, state} -> + {:ok, state, nil} + + {:ok, state, key} -> + {:ok, state, key} + + {:error, reason} -> + {:error, reason} + + _ -> + raise "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}" + end + end + + @doc false + def __mutation__(store, name, data, session, params, state) do + try do + apply(store, :mutation, [name, data, session, params, state]) + |> case do + {:reply, message, result} -> + {:reply, message, result} + + {:noreply, result} -> + {:noreply, result} + + {:error, error} -> + {:error, error} + + _ -> + {:error, + "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"} + end + rescue + FunctionClauseError -> + {:error, + "No mutation matching #{inspect(name)} with data #{inspect(data)} in store #{inspect(store)}"} + end + end + + @doc false + def __terminate__(store, session, params, state) do + if :erlang.function_exported(store, :terminate, 3) do + apply(store, :terminate, [session, params, state]) + end + end + defmacro __using__(_opts) do quote do @behaviour Storex.Store @@ -59,42 +109,26 @@ defmodule Storex.Store do end def handle_cast(:session_ended, state) do - if :erlang.function_exported(@store, :terminate, 3) do - Kernel.apply(@store, :terminate, [state.session, state.params, state.state]) - end + Storex.Store.__terminate__(@store, state.session, state.params, state.state) {:stop, :normal, state} end def handle_call({name, data}, _, state) do - try do - Kernel.apply(@store, :mutation, [name, data, state.session, state.params, state.state]) - |> case do - {:reply, message, result} -> - diff = Storex.Diff.check(state.state, result) - state = Map.put(state, :state, result) - {:reply, {:ok, message, diff}, state} - - {:noreply, result} -> - diff = Storex.Diff.check(state.state, result) - state = Map.put(state, :state, result) - {:reply, {:ok, diff}, state} - - {:error, error} -> - {:reply, {:error, error}, state} - - _ -> - {:reply, - {:error, - "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"}, - state} - end - rescue - e in FunctionClauseError -> - {:reply, - {:error, - "No mutation matching #{inspect(name)} with data #{inspect(data)} in store #{inspect(@store)}"}, - state} + Storex.Store.__mutation__(@store, name, data, state.session, state.params, state.state) + |> case do + {:reply, message, result} -> + diff = Storex.Diff.check(state.state, result) + state = Map.put(state, :state, result) + {:reply, {:ok, message, diff}, state} + + {:noreply, result} -> + diff = Storex.Diff.check(state.state, result) + state = Map.put(state, :state, result) + {:reply, {:ok, diff}, state} + + {:error, error} -> + {:reply, {:error, error}, state} end end @@ -103,20 +137,7 @@ defmodule Storex.Store do end defp init_store(session, params) do - @store.init(session, params) - |> case do - {:ok, state} -> - {:ok, state, nil} - - {:ok, state, key} -> - {:ok, state, key} - - {:error, reason} -> - {:error, reason} - - _ -> - raise "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}" - end + Storex.Store.__init__(@store, session, params) end end end diff --git a/test/fixtures/stores/invalid_init.ex b/test/fixtures/stores/invalid_init.ex new file mode 100644 index 0000000..07909cf --- /dev/null +++ b/test/fixtures/stores/invalid_init.ex @@ -0,0 +1,11 @@ +defmodule StorexTest.Store.InvalidInit do + use Storex.Store + + def init(_session, _params) do + :not_a_valid_return + end + + def mutation(_mutation, _data, _session_id, _params, state) do + {:noreply, state} + end +end diff --git a/test/fixtures/stores/invalid_mutation.ex b/test/fixtures/stores/invalid_mutation.ex new file mode 100644 index 0000000..9725bbc --- /dev/null +++ b/test/fixtures/stores/invalid_mutation.ex @@ -0,0 +1,15 @@ +defmodule StorexTest.Store.InvalidMutation do + use Storex.Store + + def init(_session, _params) do + {:ok, %{counter: 0}} + end + + def mutation("invalid", _data, _session_id, _params, _state) do + :not_a_valid_return + end + + def mutation("error", _data, _session_id, _params, _state) do + {:error, "Not allowed"} + end +end diff --git a/test/storex/store_test.exs b/test/storex/store_test.exs new file mode 100644 index 0000000..5a30786 --- /dev/null +++ b/test/storex/store_test.exs @@ -0,0 +1,103 @@ +defmodule StorexTest.StoreTest do + use ExUnit.Case + + alias StorexTest.Store.Counter + alias StorexTest.Store.ErrorInit + alias StorexTest.Store.InvalidInit + alias StorexTest.Store.InvalidMutation + alias StorexTest.Store.KeyInit + alias StorexTest.Store.Text + + describe "init dispatch" do + test "{:ok, state} is normalized with a nil key" do + assert Storex.Store.__init__(Counter, "session", %{}) == {:ok, %{counter: 0}, nil} + end + + test "{:ok, state, key} keeps the key" do + assert Storex.Store.__init__(KeyInit, "session", %{}) == {:ok, %{counter: 0}, "user_id"} + end + + test "{:error, reason} is passed through" do + assert Storex.Store.__init__(ErrorInit, "session", %{}) == {:error, "Unauthorized"} + end + + test "params are forwarded to the store" do + assert Storex.Store.__init__(Text, "session", %{"initial_value" => "custom"}) == + {:ok, "custom", nil} + end + + test "an unsupported return value raises" do + assert_raise RuntimeError, + "Return value of store init should be {:ok, state}, {:ok, state, key} or {:error, reason}", + fn -> Storex.Store.__init__(InvalidInit, "session", %{}) end + end + end + + describe "mutation dispatch" do + test "{:noreply, state} is passed through" do + assert Storex.Store.__mutation__(Counter, "increase", [], "session", %{}, %{counter: 0}) == + {:noreply, %{counter: 1}} + end + + test "{:reply, message, state} is passed through" do + assert Storex.Store.__mutation__(Counter, "decrease", [], "session", %{}, %{counter: 0}) == + {:reply, "decreased", %{counter: -1}} + end + + test "{:error, reason} is passed through" do + assert Storex.Store.__mutation__(InvalidMutation, "error", [], "session", %{}, %{}) == + {:error, "Not allowed"} + end + + test "an unsupported return value is reported as an error" do + assert Storex.Store.__mutation__(InvalidMutation, "invalid", [], "session", %{}, %{}) == + {:error, + "Return value of mutation should be {:reply, message, state}, {:noreply, state} or {:error, error}"} + end + + test "an unmatched mutation name is reported as an error" do + assert Storex.Store.__mutation__(Counter, "unknown", [1], "session", %{}, %{counter: 0}) == + {:error, + "No mutation matching \"unknown\" with data [1] in store StorexTest.Store.Counter"} + end + end + + describe "store server" do + setup do + session = "session-#{System.unique_integer([:positive])}" + + on_exit(fn -> + Storex.Registry.session_stores(session) + |> Enum.each(fn {store, _, _, _, _} -> Storex.Supervisor.remove_store(session, store) end) + end) + + %{session: session} + end + + test "starts a store returning {:ok, state}", %{session: session} do + assert {:ok, nil} = + Storex.Supervisor.add_store("StorexTest.Store.Counter", session, self(), %{}) + + assert Storex.Supervisor.get_store_state(session, "StorexTest.Store.Counter") == %{ + counter: 0 + } + end + + test "starts a store returning {:ok, state, key}", %{session: session} do + assert {:ok, "user_id"} = + Storex.Supervisor.add_store("StorexTest.Store.KeyInit", session, self(), %{}) + end + + test "does not start a store returning {:error, reason}", %{session: session} do + assert {:error, "Unauthorized"} = + Storex.Supervisor.add_store("StorexTest.Store.ErrorInit", session, self(), %{}) + end + + test "mutating through the server returns the state diff", %{session: session} do + {:ok, _} = Storex.Supervisor.add_store("StorexTest.Store.Counter", session, self(), %{}) + + assert {:ok, [%{a: "u", p: [:counter], t: 1}]} = + Storex.Supervisor.mutate_store(session, "StorexTest.Store.Counter", "increase", []) + end + end +end From 6b95364ec89b50663ef9dd55c5ec605229267991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Dro=C5=BCd=C5=BCy=C5=84ski?= Date: Sun, 6 Sep 2026 16:36:26 +0200 Subject: [PATCH 2/3] Version 0.6.2 --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- package.json | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c82d1..63303b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # storex +## 0.6.2 + +- Fix type warnings emitted by `use Storex.Store` on Elixir 1.18 and newer + ## 0.6.1 - Add missing `cast` to `Storex.Message` diff --git a/mix.exs b/mix.exs index 6233007..a624f68 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Storex.MixProject do use Mix.Project - @version "0.6.1" + @version "0.6.2" def project do [ diff --git a/package.json b/package.json index eee02ea..5503404 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storex", - "version": "0.6.1", + "version": "0.6.2", "main": "./priv/static/storex.umd.js", "module": "./priv/static/storex.esm.js", "types": "./priv/static/storex.d.ts", From 6216c9592b9c6b5f7d377b03840f5b5dbc37e2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krystian=20Dro=C5=BCd=C5=BCy=C5=84ski?= Date: Sun, 6 Sep 2026 16:45:57 +0200 Subject: [PATCH 3/3] Test Elixir 1.20 on OTP 29 in CI The matrix stopped at OTP 28, so the newest supported Erlang was not covered. Elixir 1.20 ships otp-29 builds and OTP 29.0.6 is available for ubuntu-24.04. --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 63a41cc..7fe9580 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,6 +41,7 @@ jobs: { elixir: "1.20", otp: "27", os: "ubuntu-24.04" }, { elixir: "1.20", otp: "28", os: "ubuntu-24.04" }, + { elixir: "1.20", otp: "29", os: "ubuntu-24.04" }, ] steps: - uses: actions/checkout@v4