Skip to content

Commit 27fcf08

Browse files
committed
docs: emphasize reusable reducers as core motivation and fix session_id retrieval
- Update all documentation to highlight reusable reducer patterns as primary motivation - Add 'Reducer Reusability - Core Value' section to CLAUDE.md with concrete examples - Show how reducers are defined once and composed across different session types - Fix all references from conn.assigns.session_id to get_session(conn, :session_id) - Update SessionId plug documentation to correctly show session storage location - Add CODE_PROMPT.md as comprehensive guide for Claude Code assistance - Include examples of UserSessionProcess, AdminSessionProcess, GuestSessionProcess - Explain reducer composition pattern and benefits (modularity, reusability, testability) - Fix start_session function signatures to use keyword arguments (module:, args:) - Clarify session lifecycle: start in router hooks or login, not in LiveView mount - Add missing API functions: touch/1 and session_stats/0
1 parent da65230 commit 27fcf08

File tree

4 files changed

+1057
-76
lines changed

4 files changed

+1057
-76
lines changed

CLAUDE.md

Lines changed: 199 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -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

270376
1. 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:
350457
2. Add SessionId plug after fetch_session in router
351458
3. Define custom session process modules using the `:process` macro
352459
4. 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)
354461
6. 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
512619
end
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
517661
defmodule 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
558715
end

0 commit comments

Comments
 (0)