@@ -4,7 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44
55## Project Overview
66
7- This is Phoenix.SessionProcess, an Elixir library that creates a process for each user session in Phoenix applications. All user requests go through their dedicated session process, providing session isolation and state management.
7+ This is Phoenix.SessionProcess, an Elixir library that enables ** reusable, composable reducers** for managing user session state in Phoenix applications.
8+
9+ ** Core Value Proposition** : Write state management logic once as reducers, then compose and reuse them across different session types. Each user session runs in a dedicated GenServer process with isolated state managed by Redux-style reducers.
10+
11+ ** Key Motivation** : Traditional session management makes it difficult to create reusable state management patterns. This library solves that by providing:
12+ - ** Reusable Reducers** : Define state logic once (e.g., CartReducer, UserReducer), use everywhere
13+ - ** Composable Architecture** : Mix and match reducers for different session types (guest, user, admin)
14+ - ** Redux Patterns** : Familiar actions, reducers, selectors, and subscriptions
15+ - ** Session Isolation** : Each user gets their own process with isolated state
16+ - ** Zero Dependencies** : Pure OTP/Elixir solution - no Redis, no database
817
918** Current Version** : 1.0.0 (stable release published on hex.pm)
1019** Repository** : https://github.com/gsmlg-dev/phoenix_session_process
@@ -54,6 +63,113 @@ Expected performance:
5463- Memory Usage: ~ 10KB per session
5564- Registry Lookups: 100,000+ lookups/sec
5665
66+ ## Reducer Reusability - Core Value
67+
68+ The primary motivation of this library is to enable ** reusable reducer patterns** for session state management.
69+
70+ ### Example: Define Once, Use Everywhere
71+
72+ ``` elixir
73+ # Define reusable reducers (lib/my_app/reducers/)
74+ defmodule MyApp .CartReducer do
75+ use Phoenix .SessionProcess , :reducer
76+
77+ @name :cart
78+ @action_prefix " cart"
79+
80+ @impl true
81+ def init_state , do: %{items: [], total: 0 }
82+
83+ @impl true
84+ def handle_action (action, state) do
85+ case action do
86+ %Action {type: " add_item" , payload: item} ->
87+ %{state | items: [item | state.items], total: state.total + item.price}
88+
89+ %Action {type: " clear" } ->
90+ %{items: [], total: 0 }
91+
92+ _ -> state
93+ end
94+ end
95+ end
96+
97+ defmodule MyApp .UserReducer do
98+ use Phoenix .SessionProcess , :reducer
99+
100+ @name :user
101+ @action_prefix " user"
102+
103+ @impl true
104+ def init_state , do: %{current_user: nil , authenticated: false }
105+
106+ @impl true
107+ def handle_action (action, state) do
108+ case action do
109+ %Action {type: " login" , payload: user} ->
110+ %{state | current_user: user, authenticated: true }
111+
112+ %Action {type: " logout" } ->
113+ %{current_user: nil , authenticated: false }
114+
115+ _ -> state
116+ end
117+ end
118+ end
119+ ```
120+
121+ ### Compose Reducers for Different Session Types
122+
123+ ``` elixir
124+ # Regular user sessions
125+ defmodule MyApp .UserSessionProcess do
126+ use Phoenix .SessionProcess , :process
127+
128+ @impl true
129+ def init_state (_args ), do: %{}
130+
131+ @impl true
132+ def combined_reducers do
133+ [MyApp .CartReducer , MyApp .UserReducer , MyApp .PreferencesReducer ]
134+ end
135+ end
136+
137+ # Admin sessions with additional capabilities
138+ defmodule MyApp .AdminSessionProcess do
139+ use Phoenix .SessionProcess , :process
140+
141+ @impl true
142+ def init_state (_args ), do: %{}
143+
144+ @impl true
145+ def combined_reducers do
146+ [MyApp .UserReducer , MyApp .AuditReducer , MyApp .PermissionsReducer ]
147+ # Note: Admin doesn't need cart, but reuses UserReducer
148+ end
149+ end
150+
151+ # Guest sessions with minimal state
152+ defmodule MyApp .GuestSessionProcess do
153+ use Phoenix .SessionProcess , :process
154+
155+ @impl true
156+ def init_state (_args ), do: %{}
157+
158+ @impl true
159+ def combined_reducers do
160+ [MyApp .CartReducer , MyApp .PreferencesReducer ]
161+ # Guests get cart but no user authentication
162+ end
163+ end
164+ ```
165+
166+ ** Key Benefits** :
167+ 1 . ** Write Once** : ` CartReducer ` and ` UserReducer ` are defined once
168+ 2 . ** Use Everywhere** : Mix and match for different session types
169+ 3 . ** Testable** : Test each reducer independently
170+ 4 . ** Maintainable** : Change cart logic in one place, affects all sessions that use it
171+ 5 . ** Type Safe** : Compile-time validation ensures correct structure
172+
57173## Architecture
58174
59175### Module Organization
@@ -82,9 +198,6 @@ The library is organized into several logical groups:
82198- ` Phoenix.SessionProcess.Config ` - Configuration management
83199- ` Phoenix.SessionProcess.Error ` - Error types and messages
84200
85- ** LiveView Integration** :
86- - ` Phoenix.SessionProcess.LiveView ` - LiveView integration helpers with Redux Store API
87-
88201** Observability** :
89202- ` Phoenix.SessionProcess.Telemetry ` - Telemetry event emission
90203- ` Phoenix.SessionProcess.TelemetryLogger ` - Logging integration
@@ -103,7 +216,9 @@ The library is organized into several logical groups:
103216 - ` cast/2 ` - Asynchronous cast to session
104217 - ` terminate/1 ` - Stop session
105218 - ` started?/1 ` - Check if session exists
219+ - ` touch/1 ` - Reset session TTL (extend session lifetime)
106220 - ` list_session/0 ` - List all sessions
221+ - ` session_stats/0 ` - Get memory and performance statistics
107222
108223 ** Redux Store API (v1.0.0)** - SessionProcess IS the Redux store:
109224 - ` dispatch/4 ` - Dispatch actions: ` dispatch(session_id, type, payload \\ nil, meta \\ []) `
@@ -256,15 +371,6 @@ The library is organized into several logical groups:
256371 - Schedules session expiration on creation
257372 - Runs cleanup tasks periodically
258373
259- 10 . ** Phoenix.SessionProcess.LiveView** (lib/phoenix/session_process/live_view.ex:1)
260- - LiveView integration helpers for Redux Store API
261- - ` mount_store/4 ` - Mount with direct SessionProcess subscriptions
262- - ` unmount_store/1 ` - Unmount (optional, automatic cleanup via monitoring)
263- - ` dispatch_store/3-4 ` - Dispatch actions (sync/async)
264- - Uses SessionProcess subscriptions (not PubSub)
265- - Selector-based updates for efficiency
266- - Automatic cleanup via process monitoring
267-
268374### Process Management Flow
269375
2703761 . Session ID generation via the SessionId plug
@@ -313,10 +419,11 @@ The library is organized into several logical groups:
313419 - Useful for debugging action routing issues in complex applications
314420
315421- ** LiveView Integration** :
316- - Use ` Phoenix.SessionProcess.LiveView.mount_store /4 ` for direct subscriptions
317- - Selector-based updates for efficiency
422+ - Use ` Phoenix.SessionProcess.subscribe /4 ` directly in LiveView mount
423+ - Selector-based subscriptions for efficient state updates
318424 - Message format: ` {event_name, selected_value} `
319- - Automatic cleanup when LiveView terminates
425+ - Automatic cleanup via process monitoring when LiveView terminates
426+ - Use ` dispatch/4 ` or ` dispatch_async/4 ` to update state from LiveView events
320427
321428- Telemetry events for all lifecycle operations (start, stop, call, cast, cleanup, errors)
322429- Comprehensive error handling with Phoenix.SessionProcess.Error module
@@ -350,9 +457,9 @@ Configuration options:
3504572 . Add SessionId plug after fetch_session in router
3514583 . Define custom session process modules using the ` :process ` macro
3524594 . Define reducers using the ` :reducer ` macro (v1.0.0+)
353- 5 . Start processes with session IDs
460+ 5 . Start sessions early in request lifecycle (router hook or login controller)
3544616 . Dispatch actions using ` dispatch/4 ` with binary action types
355- 7 . For LiveView integration, use ` Phoenix.SessionProcess.LiveView ` helpers
462+ 7 . For LiveView integration, use ` Phoenix.SessionProcess.subscribe/4 ` and ` dispatch/4 ` directly (session should already be started)
356463
357464### Complete Example (v1.0.0)
358465
@@ -488,7 +595,7 @@ defmodule MyAppWeb.PageController do
488595 alias Phoenix .SessionProcess
489596
490597 def index (conn, _params ) do
491- session_id = conn.assigns. session_id
598+ session_id = get_session ( conn, : session_id)
492599
493600 # Start session
494601 {:ok , _pid } = SessionProcess .start_session (session_id)
@@ -512,47 +619,97 @@ defmodule MyAppWeb.PageController do
512619end
513620```
514621
515- ** 6. LiveView Integration:**
622+ ** 6. Start Sessions (Router Hook or Login):**
623+
624+ Sessions should be started early in the request lifecycle, not in LiveView mount.
625+
626+ ** Option A: Router Hook**
627+ ``` elixir
628+ # lib/my_app_web/router.ex
629+ pipeline :browser do
630+ plug :fetch_session
631+ plug Phoenix .SessionProcess .SessionId
632+ plug :ensure_session_process # Start session here
633+ end
634+
635+ defp ensure_session_process (conn, _opts ) do
636+ session_id = get_session (conn, :session_id )
637+
638+ case SessionProcess .start_session (session_id) do
639+ {:ok , _pid } -> conn
640+ {:error , {:already_started , _pid }} -> conn
641+ {:error , _reason } ->
642+ conn |> put_flash (:error , " Session unavailable" ) |> halt ()
643+ end
644+ end
645+ ```
646+
647+ ** Option B: At Login**
648+ ``` elixir
649+ def login (conn, %{" user" => params}) do
650+ with {:ok , user} <- authenticate (params),
651+ session_id <- get_session (conn, :session_id ),
652+ {:ok , _pid } <- SessionProcess .start_session (session_id) do
653+ :ok = SessionProcess .dispatch (session_id, " user.set" , user)
654+ redirect (conn, to: " /dashboard" )
655+ end
656+ end
657+ ```
658+
659+ ** 7. LiveView Integration:**
516660``` elixir
517661defmodule MyAppWeb .DashboardLive do
518662 use Phoenix .LiveView
519663 alias Phoenix .SessionProcess
520- alias Phoenix .SessionProcess .LiveView , as: SessionLV
521664
522665 def mount (_params , %{" session_id" => session_id}, socket) do
523- # Mount with store subscription
524- case SessionLV .mount_store (
525- socket,
526- session_id,
527- fn state -> state.counter.count end ,
528- :count_changed
529- ) do
530- {:ok , socket, initial_count} ->
531- {:ok , assign (socket, count: initial_count, session_id: session_id)}
532- {:error , _ } ->
533- {:ok , socket}
666+ # Session should already exist (started in router hook or login)
667+ if SessionProcess .started? (session_id) do
668+ # Subscribe to state changes with a selector
669+ {:ok , sub_id} = SessionProcess .subscribe (
670+ session_id,
671+ fn state -> state.counter.count end , # Selector function
672+ :count_changed , # Event name for messages
673+ self () # Subscriber pid (defaults to self())
674+ )
675+
676+ # Get initial state
677+ initial_count = SessionProcess .get_state (session_id, fn s -> s.counter.count end )
678+
679+ {:ok , assign (socket, session_id: session_id, subscription_id: sub_id, count: initial_count)}
680+ else
681+ {:ok , push_redirect (socket, to: " /login" )}
534682 end
535683 end
536684
537- # Receive state updates
685+ # Handle state change messages from subscription
538686 def handle_info ({:count_changed , new_count}, socket) do
539687 {:noreply , assign (socket, count: new_count)}
540688 end
541689
542- # Dispatch actions (MUST use binary types)
690+ # Dispatch actions to update state (MUST use binary types)
543691 def handle_event (" increment" , _params , socket) do
544- # Async dispatch returns cancellation
545- {:ok , _cancel_fn } = SessionLV .dispatch_store (
546- socket.assigns.session_id,
547- " counter.increment" ,
548- async: true
549- )
692+ :ok = SessionProcess .dispatch (socket.assigns.session_id, " counter.increment" )
693+ {:noreply , socket}
694+ end
695+
696+ def handle_event (" decrement" , _params , socket) do
697+ # Can also dispatch async
698+ :ok = SessionProcess .dispatch_async (socket.assigns.session_id, " counter.decrement" )
699+ {:noreply , socket}
700+ end
701+
702+ def handle_event (" set_value" , %{" value" => value}, socket) do
703+ # Dispatch with payload
704+ :ok = SessionProcess .dispatch (socket.assigns.session_id, " counter.set" , String .to_integer (value))
550705 {:noreply , socket}
551706 end
552707
553708 def terminate (_reason , socket) do
554- # Cleanup is automatic via process monitoring
555- SessionLV .unmount_store (socket)
709+ # Cleanup is automatic via process monitoring, but can explicitly unsubscribe if needed
710+ if sub_id = socket.assigns[:subscription_id ] do
711+ SessionProcess .unsubscribe (socket.assigns.session_id, sub_id)
712+ end
556713 :ok
557714 end
558715end
0 commit comments