Split Elixir guide into 4 focused parts for modular loading
This commit is contained in:
parent
72fa304b14
commit
8fc59eee23
1463
elixir/SKILL.md
1463
elixir/SKILL.md
File diff suppressed because it is too large
Load Diff
208
elixir/elixir-part1-core.md
Normal file
208
elixir/elixir-part1-core.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Elixir Part 1: Core Language & Modern Idioms
|
||||
|
||||
## Elixir 1.19 — What's New
|
||||
|
||||
### Gradual Set-Theoretic Type System
|
||||
|
||||
Elixir 1.19 significantly advances the built-in type system. It is **sound**, **gradual**, and **set-theoretic** (types compose via union, intersection, negation).
|
||||
|
||||
**Current capabilities (1.19):**
|
||||
- Type inference from existing code — no annotations required yet
|
||||
- **Protocol dispatch type checking** — warns on invalid protocol usage (e.g., string interpolation on structs without `String.Chars`)
|
||||
- **Anonymous function type inference** — `fn` literals and `&captures` propagate types
|
||||
- Types: `atom()`, `binary()`, `integer()`, `float()`, `pid()`, `port()`, `reference()`, `tuple()`, `list()`, `map()`, `function()`
|
||||
- Compose: `atom() or integer()` (union), `atom() and not nil` (difference)
|
||||
- `dynamic()` type for gradual typing — runtime-checked values
|
||||
- Tuple precision: `{:ok, binary()}`, open tuples: `{:ok, binary(), ...}`
|
||||
- Map types: `%{key: value}` closed, `%{optional(atom()) => term()}` open
|
||||
- Function types: `(integer() -> boolean())`
|
||||
|
||||
### Up to 4x Faster Compilation
|
||||
|
||||
1. **Lazy module loading** — modules no longer loaded when defined; parallel compiler controls loading
|
||||
2. **Parallel dependency compilation** — set `MIX_OS_DEPS_COMPILE_PARTITION_COUNT` env var
|
||||
|
||||
**Caveat:** If spawning processes during compilation that invoke sibling modules, use `Code.ensure_compiled!/1` first.
|
||||
|
||||
### Other 1.19 Features
|
||||
|
||||
- `min/2` and `max/2` allowed in guards
|
||||
- `Access.values/0` — traverse all values in a map/keyword
|
||||
- `String.count/2` — count occurrences
|
||||
- Unicode 17.0.0, multi-line IEx prompts
|
||||
- `mix help Mod.fun/arity`
|
||||
- Erlang/OTP 28 support
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes Since 1.15
|
||||
|
||||
### Struct Update Syntax (1.18+)
|
||||
```elixir
|
||||
# The compiler now verifies the variable matches the struct type
|
||||
%URI{my_uri | path: "/new"} # my_uri must be verified as %URI{}
|
||||
```
|
||||
|
||||
### Regex as Struct Field Defaults (OTP 28)
|
||||
```elixir
|
||||
# BROKEN on OTP 28 — regex can't be struct field defaults
|
||||
defstruct pattern: ~r/foo/ # Compile error
|
||||
# FIX: use nil default, set at runtime
|
||||
```
|
||||
|
||||
### Logger Backends Deprecated
|
||||
```elixir
|
||||
# OLD: config :logger, backends: [:console]
|
||||
# NEW: use LoggerHandler (Erlang's logger)
|
||||
config :logger, :default_handler, []
|
||||
```
|
||||
|
||||
### Mix Task Separator
|
||||
```elixir
|
||||
# OLD: mix do compile, test
|
||||
# NEW: mix do compile + test
|
||||
```
|
||||
|
||||
### mix cli/0 Replaces Config Keys
|
||||
```elixir
|
||||
# OLD: default_task, preferred_cli_env in project/0
|
||||
# NEW: def cli, do: [default_task: "phx.server", preferred_envs: [test: :test]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Language Patterns
|
||||
|
||||
### The Pipeline
|
||||
```elixir
|
||||
orders
|
||||
|> Enum.filter(&(&1.status == :pending))
|
||||
|> Enum.sort_by(& &1.created_at, DateTime)
|
||||
|> Enum.map(&process_order/1)
|
||||
```
|
||||
|
||||
### Pattern Matching
|
||||
```elixir
|
||||
# Function head matching — preferred over conditionals
|
||||
def process(%Order{status: :pending} = order), do: ship(order)
|
||||
def process(%Order{status: :shipped} = order), do: track(order)
|
||||
def process(%Order{status: :delivered}), do: :noop
|
||||
|
||||
# Pin operator
|
||||
expected = "hello"
|
||||
^expected = some_function()
|
||||
|
||||
# Partial map matching
|
||||
%{name: name} = %{name: "Michael", age: 42}
|
||||
```
|
||||
|
||||
### With Expressions
|
||||
```elixir
|
||||
with {:ok, user} <- fetch_user(id),
|
||||
{:ok, account} <- fetch_account(user),
|
||||
{:ok, balance} <- check_balance(account) do
|
||||
{:ok, balance}
|
||||
else
|
||||
{:error, :not_found} -> {:error, "User not found"}
|
||||
error -> {:error, "Unknown: #{inspect(error)}"}
|
||||
end
|
||||
```
|
||||
|
||||
### Structs and Protocols
|
||||
```elixir
|
||||
defmodule Money do
|
||||
defstruct [:amount, :currency]
|
||||
|
||||
defimpl String.Chars do
|
||||
def to_string(%Money{amount: a, currency: c}), do: "#{a} #{c}"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Behaviours
|
||||
```elixir
|
||||
defmodule PaymentProvider do
|
||||
@callback charge(integer(), String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||
@callback refund(String.t()) :: {:ok, term()} | {:error, term()}
|
||||
end
|
||||
|
||||
defmodule Stripe do
|
||||
@behaviour PaymentProvider
|
||||
@impl true
|
||||
def charge(amount, currency), do: # ...
|
||||
@impl true
|
||||
def refund(transaction_id), do: # ...
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mix Project Structure
|
||||
|
||||
```
|
||||
my_app/
|
||||
├── config/
|
||||
│ ├── config.exs # Compile-time config
|
||||
│ ├── dev.exs / prod.exs / test.exs
|
||||
│ └── runtime.exs # Runtime config (secrets, env vars)
|
||||
├── lib/
|
||||
│ ├── my_app/
|
||||
│ │ ├── application.ex # OTP Application + supervisor tree
|
||||
│ │ └── ... # Domain modules
|
||||
│ └── my_app_web/ # Phoenix web layer (if applicable)
|
||||
├── priv/repo/migrations/
|
||||
├── test/
|
||||
├── mix.exs
|
||||
├── .formatter.exs
|
||||
└── AGENTS.md # Generated by usage_rules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.BlogTest do
|
||||
use MyApp.DataCase, async: true
|
||||
|
||||
describe "creating posts" do
|
||||
test "creates with valid attributes" do
|
||||
assert {:ok, post} = Blog.create_post(%{title: "Hi", body: "World"})
|
||||
assert post.title == "Hi"
|
||||
end
|
||||
|
||||
test "fails without title" do
|
||||
assert {:error, changeset} = Blog.create_post(%{body: "World"})
|
||||
assert "can't be blank" in errors_on(changeset).title
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Use `async: true` wherever possible. Use `Mox` for behaviour-based mocking.
|
||||
|
||||
---
|
||||
|
||||
## usage_rules — AI Agent Documentation
|
||||
|
||||
**Always include in every project.**
|
||||
|
||||
```elixir
|
||||
# mix.exs deps
|
||||
{:usage_rules, "~> 1.1", only: [:dev]}
|
||||
|
||||
# mix.exs project config
|
||||
usage_rules: [packages: :all, output: :agents_md, mode: :linked]
|
||||
```
|
||||
|
||||
```bash
|
||||
mix usage_rules.gen # Generate AGENTS.md from deps
|
||||
mix usage_rules.search_docs # Search hex documentation
|
||||
mix usage_rules.gen_skill # Generate SKILL.md for Cowork
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pro Tip: Local Docs
|
||||
|
||||
Elixir packages ship excellent local documentation. Once Elixir is installed on cortex, accessing docs locally via `mix hex.docs fetch <package>` or `h Module.function` in IEx may be more efficient than fetching URLs. Consider installing Elixir on cortex to enable this workflow.
|
||||
278
elixir/elixir-part2-concurrency.md
Normal file
278
elixir/elixir-part2-concurrency.md
Normal file
@ -0,0 +1,278 @@
|
||||
# Elixir Part 2: Concurrency, OTP & State Machines
|
||||
|
||||
## BEAM Process Fundamentals
|
||||
|
||||
- **Processes are cheap** — ~2KB initial memory, microseconds to spawn, millions concurrent
|
||||
- **No shared memory** — message passing only
|
||||
- **Per-process GC** — no stop-the-world pauses
|
||||
- **Preemptive scheduling** — one scheduler per CPU core, ~4000 reductions then yield
|
||||
- **Process isolation** — one crash doesn't affect others
|
||||
|
||||
### Spawning and Messaging
|
||||
```elixir
|
||||
pid = spawn(fn ->
|
||||
receive do
|
||||
{:greet, name} -> IO.puts("Hello, #{name}!")
|
||||
end
|
||||
end)
|
||||
send(pid, {:greet, "Michael"})
|
||||
|
||||
# Linked — bidirectional crash propagation
|
||||
pid = spawn_link(fn -> raise "boom" end)
|
||||
|
||||
# Monitored — one-directional crash notification
|
||||
ref = Process.monitor(pid)
|
||||
receive do
|
||||
{:DOWN, ^ref, :process, ^pid, reason} -> handle_crash(reason)
|
||||
end
|
||||
```
|
||||
|
||||
### Task — Structured Concurrency
|
||||
```elixir
|
||||
Task.start(fn -> send_email(user) end) # Fire and forget
|
||||
task = Task.async(fn -> expensive_computation() end) # Async/await
|
||||
result = Task.await(task, 30_000)
|
||||
|
||||
Task.async_stream(urls, &fetch_url/1, max_concurrency: 10) # Parallel map
|
||||
|> Enum.to_list()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GenServer — Stateful Server Processes
|
||||
|
||||
```elixir
|
||||
defmodule Counter do
|
||||
use GenServer
|
||||
|
||||
# Client API
|
||||
def start_link(initial \\ 0), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
|
||||
def increment, do: GenServer.cast(__MODULE__, :increment)
|
||||
def get, do: GenServer.call(__MODULE__, :get)
|
||||
|
||||
# Server callbacks
|
||||
@impl true
|
||||
def init(initial), do: {:ok, initial}
|
||||
|
||||
@impl true
|
||||
def handle_cast(:increment, count), do: {:noreply, count + 1}
|
||||
|
||||
@impl true
|
||||
def handle_call(:get, _from, count), do: {:reply, count, count}
|
||||
|
||||
@impl true
|
||||
def handle_info(:tick, count) do
|
||||
Process.send_after(self(), :tick, 1000)
|
||||
{:noreply, count}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Key principle:** Callbacks run sequentially — this is both the synchronization mechanism and potential bottleneck. Keep callbacks fast; delegate heavy work to spawned tasks.
|
||||
|
||||
---
|
||||
|
||||
## GenStateMachine — State Machines for Agentic Workflows
|
||||
|
||||
**Why GenStateMachine over GenServer for agents?** GenServer has a single state and handles all messages uniformly. GenStateMachine (wrapping Erlang's `:gen_statem`) provides:
|
||||
- **Explicit states** with per-state event handling
|
||||
- **Built-in timeouts** — state timeouts (reset on state change), generic timeouts, event timeouts
|
||||
- **Postpone** — defer events until the right state
|
||||
- **State enter callbacks** — run setup logic when entering a state
|
||||
- **Event types** — distinguish calls, casts, info, timeouts, internal events
|
||||
|
||||
These map naturally onto agentic patterns: an agent in "thinking" state ignores new requests (postpone), has retry timeouts, transitions through well-defined phases, and runs setup on each state entry.
|
||||
|
||||
### Installation
|
||||
```elixir
|
||||
{:gen_state_machine, "~> 3.0"}
|
||||
```
|
||||
|
||||
### Callback Modes
|
||||
|
||||
**`:handle_event_function`** (default) — single `handle_event/4` for all states:
|
||||
```elixir
|
||||
defmodule AgentFSM do
|
||||
use GenStateMachine
|
||||
|
||||
# Client API
|
||||
def start_link(opts), do: GenStateMachine.start_link(__MODULE__, opts)
|
||||
def submit(pid, task), do: GenStateMachine.call(pid, {:submit, task})
|
||||
def status(pid), do: GenStateMachine.call(pid, :status)
|
||||
|
||||
# Server callbacks
|
||||
def init(opts) do
|
||||
{:ok, :idle, %{tasks: [], results: [], config: opts}}
|
||||
end
|
||||
|
||||
# State: idle
|
||||
def handle_event({:call, from}, {:submit, task}, :idle, data) do
|
||||
{:next_state, :processing, %{data | tasks: [task]},
|
||||
[{:reply, from, :accepted}, {:state_timeout, 30_000, :timeout}]}
|
||||
end
|
||||
|
||||
def handle_event({:call, from}, :status, state, data) do
|
||||
{:keep_state_and_data, [{:reply, from, {state, length(data.results)}}]}
|
||||
end
|
||||
|
||||
# State: processing — with timeout
|
||||
def handle_event(:state_timeout, :timeout, :processing, data) do
|
||||
{:next_state, :error, %{data | error: :timeout}}
|
||||
end
|
||||
|
||||
# Internal event for completion
|
||||
def handle_event(:info, {:result, result}, :processing, data) do
|
||||
{:next_state, :complete, %{data | results: [result | data.results]}}
|
||||
end
|
||||
|
||||
# Postpone submissions while processing
|
||||
def handle_event({:call, _from}, {:submit, _task}, :processing, _data) do
|
||||
{:keep_state_and_data, :postpone}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**`:state_functions`** — each state is a separate function (states must be atoms):
|
||||
```elixir
|
||||
defmodule WorkflowFSM do
|
||||
use GenStateMachine, callback_mode: [:state_functions, :state_enter]
|
||||
|
||||
def init(_), do: {:ok, :pending, %{}}
|
||||
|
||||
# State enter callbacks — run on every state transition
|
||||
def pending(:enter, _old_state, data) do
|
||||
{:keep_state, %{data | entered_at: DateTime.utc_now()}}
|
||||
end
|
||||
|
||||
def pending({:call, from}, {:start, params}, data) do
|
||||
{:next_state, :running, %{data | params: params},
|
||||
[{:reply, from, :ok}, {:state_timeout, 60_000, :execution_timeout}]}
|
||||
end
|
||||
|
||||
def running(:enter, :pending, data) do
|
||||
# Setup when entering running from pending
|
||||
send(self(), :execute)
|
||||
{:keep_state, data}
|
||||
end
|
||||
|
||||
def running(:info, :execute, data) do
|
||||
result = do_work(data.params)
|
||||
{:next_state, :complete, %{data | result: result}}
|
||||
end
|
||||
|
||||
def running(:state_timeout, :execution_timeout, data) do
|
||||
{:next_state, :failed, %{data | error: :timeout}}
|
||||
end
|
||||
|
||||
# Postpone any calls while running
|
||||
def running({:call, _from}, _request, _data) do
|
||||
{:keep_state_and_data, :postpone}
|
||||
end
|
||||
|
||||
def complete(:enter, _old, data), do: {:keep_state, data}
|
||||
def complete({:call, from}, :get_result, data) do
|
||||
{:keep_state_and_data, [{:reply, from, {:ok, data.result}}]}
|
||||
end
|
||||
|
||||
def failed(:enter, _old, data), do: {:keep_state, data}
|
||||
def failed({:call, from}, :get_error, data) do
|
||||
{:keep_state_and_data, [{:reply, from, {:error, data.error}}]}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Timeout Types
|
||||
|
||||
| Type | Behavior | Use Case |
|
||||
|------|----------|----------|
|
||||
| `{:timeout, ms, event}` | Generic — survives state changes | Periodic polling |
|
||||
| `{:state_timeout, ms, event}` | Resets on state change | Per-state deadlines |
|
||||
| `{:event_timeout, ms, event}` | Resets on any event | Inactivity detection |
|
||||
|
||||
### Key Actions in Return Tuples
|
||||
|
||||
```elixir
|
||||
# Return format: {:next_state, new_state, new_data, actions}
|
||||
actions = [
|
||||
{:reply, from, response}, # Reply to caller
|
||||
{:state_timeout, 30_000, :deadline}, # State-scoped timeout
|
||||
{:timeout, 5_000, :poll}, # Generic timeout
|
||||
:postpone, # Defer event to next state
|
||||
:hibernate, # Reduce memory footprint
|
||||
{:next_event, :internal, :setup} # Queue internal event
|
||||
]
|
||||
```
|
||||
|
||||
### When to Use GenStateMachine vs GenServer
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Simple key-value state, CRUD | GenServer |
|
||||
| Request/response server | GenServer |
|
||||
| Well-defined state transitions | **GenStateMachine** |
|
||||
| Need built-in timeouts per state | **GenStateMachine** |
|
||||
| Events valid only in certain states | **GenStateMachine** |
|
||||
| Agentic workflow with phases | **GenStateMachine** |
|
||||
| Need postpone/defer semantics | **GenStateMachine** |
|
||||
|
||||
---
|
||||
|
||||
## Supervisors — Let It Crash
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Application do
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
MyApp.Repo,
|
||||
{MyApp.Cache, []},
|
||||
{Task.Supervisor, name: MyApp.TaskSupervisor},
|
||||
MyAppWeb.Endpoint
|
||||
]
|
||||
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Strategies:**
|
||||
- `:one_for_one` — restart only crashed child (most common)
|
||||
- `:one_for_all` — restart all if any crashes (tightly coupled)
|
||||
- `:rest_for_one` — restart crashed + all started after it
|
||||
|
||||
### DynamicSupervisor
|
||||
```elixir
|
||||
defmodule MyApp.SessionSupervisor do
|
||||
use DynamicSupervisor
|
||||
|
||||
def start_link(_), do: DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
|
||||
def init(:ok), do: DynamicSupervisor.init(strategy: :one_for_one)
|
||||
|
||||
def start_session(session_id) do
|
||||
DynamicSupervisor.start_child(__MODULE__, {MyApp.Session, session_id})
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registry & Process Discovery
|
||||
|
||||
```elixir
|
||||
# In supervisor
|
||||
{Registry, keys: :unique, name: MyApp.Registry}
|
||||
|
||||
# Register a process
|
||||
Registry.register(MyApp.Registry, "session:#{id}", %{})
|
||||
|
||||
# Lookup
|
||||
case Registry.lookup(MyApp.Registry, "session:#{id}") do
|
||||
[{pid, _value}] -> {:ok, pid}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
|
||||
# Use as GenServer name
|
||||
GenServer.start_link(MyWorker, arg, name: {:via, Registry, {MyApp.Registry, "worker:#{id}"}})
|
||||
```
|
||||
628
elixir/elixir-part3-phoenix.md
Normal file
628
elixir/elixir-part3-phoenix.md
Normal file
@ -0,0 +1,628 @@
|
||||
# Elixir Part 3: Phoenix Framework — v1.8.5 (Current)
|
||||
|
||||
Phoenix is the web framework for Elixir. Version 1.8.5 is current. It provides MVC controllers, real-time LiveView, Channels for WebSocket communication, and comprehensive tooling for authentication, testing, and deployment.
|
||||
|
||||
## What's New in Phoenix 1.8
|
||||
|
||||
- **Scopes** in generators — secure data access by default (e.g., `current_user` automatically applied)
|
||||
- **Magic links** (passwordless auth) and **"sudo mode"** in `mix phx.gen.auth`
|
||||
- **daisyUI** integration — light/dark/system mode support out of the box
|
||||
- **Simplified layouts** — single `root.html.heex` wraps everything; dynamic layouts are function components
|
||||
- **`use Phoenix.Controller` now requires `:formats`** — must specify `formats: [:html]` or `formats: [:html, :json]`
|
||||
- **Updated security headers** — `content-security-policy` with `base-uri 'self'; frame-ancestors 'self'`; dropped deprecated `x-frame-options` and `x-download-options`
|
||||
- **`config` variable removed** from `Phoenix.Endpoint` — use `Application.compile_env/3` instead
|
||||
- **Deprecated**: `:namespace`, `:put_default_views`, layouts without modules, `:trailing_slash` in router
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
```bash
|
||||
# New project (Phoenix Express — quickest path)
|
||||
curl https://new.phoenixframework.org/my_app | sh
|
||||
|
||||
# Traditional setup
|
||||
mix phx.new my_app
|
||||
mix phx.new my_app --no-ecto # Without database
|
||||
mix phx.new my_app --no-html # API only
|
||||
mix phx.new my_app --database sqlite3 # SQLite instead of Postgres
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
my_app/
|
||||
├── lib/
|
||||
│ ├── my_app/ # Business logic (contexts, schemas)
|
||||
│ │ ├── application.ex # Supervision tree
|
||||
│ │ ├── repo.ex # Ecto Repo
|
||||
│ │ └── catalog/ # Context: Catalog
|
||||
│ │ ├── product.ex # Schema
|
||||
│ │ └── catalog.ex # Context module
|
||||
│ └── my_app_web/ # Web layer
|
||||
│ ├── endpoint.ex # HTTP entry point
|
||||
│ ├── router.ex # Routes + pipelines
|
||||
│ ├── components/
|
||||
│ │ ├── core_components.ex # Shared UI components
|
||||
│ │ └── layouts.ex # Layout components
|
||||
│ ├── controllers/
|
||||
│ │ ├── page_controller.ex
|
||||
│ │ └── page_html/ # Templates for controller
|
||||
│ │ └── home.html.heex
|
||||
│ └── live/ # LiveView modules
|
||||
│ └── counter_live.ex
|
||||
├── config/
|
||||
│ ├── config.exs # Compile-time config
|
||||
│ ├── dev.exs / prod.exs
|
||||
│ └── runtime.exs # Runtime config (env vars, secrets)
|
||||
├── priv/
|
||||
│ ├── repo/migrations/
|
||||
│ └── static/ # Static assets
|
||||
├── test/
|
||||
│ ├── support/
|
||||
│ │ ├── conn_case.ex # Test helpers for controllers
|
||||
│ │ └── data_case.ex # Test helpers for data layer
|
||||
│ └── my_app_web/
|
||||
└── mix.exs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Router & Pipelines
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.Router do
|
||||
use MyAppWeb, :router
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
scope "/", MyAppWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
live "/counter", CounterLive # LiveView route
|
||||
resources "/products", ProductController # RESTful CRUD
|
||||
end
|
||||
|
||||
scope "/api", MyAppWeb do
|
||||
pipe_through :api
|
||||
|
||||
resources "/items", ItemController, except: [:new, :edit]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verified Routes — `~p` Sigil
|
||||
|
||||
**Always use `~p` instead of string paths.** Compile-time verified against your router.
|
||||
|
||||
```elixir
|
||||
# In templates (HEEx)
|
||||
~H"""
|
||||
<a href={~p"/products/#{product}"}>View</a>
|
||||
<a href={~p"/products/#{product}/edit"}>Edit</a>
|
||||
"""
|
||||
|
||||
# In controllers
|
||||
redirect(conn, to: ~p"/products/#{product}")
|
||||
|
||||
# With query params
|
||||
~p"/products?page=#{page}&sort=name"
|
||||
~p"/products?#{%{page: 1, sort: "name"}}"
|
||||
|
||||
# URL generation (includes host)
|
||||
url(~p"/products/#{product}")
|
||||
# => "https://example.com/products/42"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controllers (1.8 Style)
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.ProductController do
|
||||
use MyAppWeb, :controller
|
||||
|
||||
# REQUIRED in 1.8: specify formats
|
||||
# This is typically set in MyAppWeb :controller function
|
||||
|
||||
def index(conn, _params) do
|
||||
products = Catalog.list_products()
|
||||
render(conn, :index, products: products)
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
product = Catalog.get_product!(id)
|
||||
render(conn, :show, product: product)
|
||||
end
|
||||
|
||||
def create(conn, %{"product" => product_params}) do
|
||||
case Catalog.create_product(product_params) do
|
||||
{:ok, product} ->
|
||||
conn
|
||||
|> put_flash(:info, "Product created.")
|
||||
|> redirect(to: ~p"/products/#{product}")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**View module naming:** For `ProductController`, Phoenix looks for `ProductHTML` (for `:html` format) and `ProductJSON` (for `:json` format).
|
||||
|
||||
```elixir
|
||||
# lib/my_app_web/controllers/product_html.ex
|
||||
defmodule MyAppWeb.ProductHTML do
|
||||
use MyAppWeb, :html
|
||||
embed_templates "product_html/*" # Loads .heex files from directory
|
||||
end
|
||||
|
||||
# lib/my_app_web/controllers/product_json.ex
|
||||
defmodule MyAppWeb.ProductJSON do
|
||||
def index(%{products: products}), do: %{data: for(p <- products, do: data(p))}
|
||||
def show(%{product: product}), do: %{data: data(product)}
|
||||
defp data(product), do: %{id: product.id, title: product.title, price: product.price}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components and HEEx
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.CoreComponents do
|
||||
use Phoenix.Component
|
||||
|
||||
# Declare attributes with types and docs
|
||||
attr :type, :string, default: "button"
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global # Passes through all other HTML attrs
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button type={@type} class={["btn", @class]} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
# Table component with slots
|
||||
attr :rows, :list, required: true
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
def table(assigns) do
|
||||
~H"""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={row <- @rows}>
|
||||
<td :for={col <- @col}>{render_slot(col, row)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**HEEx syntax notes:**
|
||||
- `{@var}` — render assign (curly braces, not `<%= %>`)
|
||||
- `:if={condition}` — conditional rendering on any tag
|
||||
- `:for={item <- list}` — iteration on any tag
|
||||
- `<.component_name />` — call function component with dot notation
|
||||
- `<Component.name />` — call remote component with module name
|
||||
- `{render_slot(@inner_block)}` — render slot content
|
||||
- `<:slot_name>content</:slot_name>` — named slot content
|
||||
|
||||
---
|
||||
|
||||
## LiveView
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.SearchLive do
|
||||
use MyAppWeb, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, query: "", results: [])}
|
||||
end
|
||||
|
||||
def handle_params(%{"q" => query}, _uri, socket) do
|
||||
{:noreply, assign(socket, query: query, results: search(query))}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(query: query, results: search(query))
|
||||
|> push_patch(to: ~p"/search?q=#{query}")}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<form phx-submit="search">
|
||||
<input name="query" value={@query} phx-debounce="300" />
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<div :for={result <- @results} class="result">
|
||||
<h3>{result.title}</h3>
|
||||
<p>{result.summary}</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp search(query), do: MyApp.Search.find(query)
|
||||
end
|
||||
```
|
||||
|
||||
**LiveView lifecycle:** `mount/3` → `handle_params/3` → `render/1`. Events via `handle_event/3`. Server pushes via `handle_info/2`.
|
||||
|
||||
**Key patterns:**
|
||||
- `assign/2,3` — set socket assigns
|
||||
- `push_navigate/2` — navigate to new LiveView (full mount)
|
||||
- `push_patch/2` — update URL without full remount (calls `handle_params`)
|
||||
- `push_event/3` — push event to client JS hooks
|
||||
- `stream/3,4` — efficient list rendering for large collections (inserts/deletes without re-rendering entire list)
|
||||
- `async_assign/3` + `assign_async/3` — async data loading with loading/error states
|
||||
|
||||
---
|
||||
|
||||
## Channels — Real-Time WebSocket
|
||||
|
||||
```elixir
|
||||
# In endpoint.ex
|
||||
socket "/socket", MyAppWeb.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
|
||||
# lib/my_app_web/channels/user_socket.ex
|
||||
defmodule MyAppWeb.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
channel "room:*", MyAppWeb.RoomChannel
|
||||
|
||||
def connect(%{"token" => token}, socket, _connect_info) do
|
||||
case Phoenix.Token.verify(socket, "user auth", token, max_age: 86400) do
|
||||
{:ok, user_id} -> {:ok, assign(socket, :user_id, user_id)}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def id(socket), do: "users_socket:#{socket.assigns.user_id}"
|
||||
end
|
||||
|
||||
# lib/my_app_web/channels/room_channel.ex
|
||||
defmodule MyAppWeb.RoomChannel do
|
||||
use MyAppWeb, :channel
|
||||
|
||||
def join("room:" <> room_id, _params, socket) do
|
||||
{:ok, assign(socket, :room_id, room_id)}
|
||||
end
|
||||
|
||||
def handle_in("new_msg", %{"body" => body}, socket) do
|
||||
broadcast!(socket, "new_msg", %{
|
||||
body: body,
|
||||
user_id: socket.assigns.user_id
|
||||
})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Force disconnect all sessions for a user:**
|
||||
```elixir
|
||||
MyAppWeb.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contexts — Business Logic Boundary
|
||||
|
||||
Contexts are plain Elixir modules that encapsulate data access and business rules. They are the API between your web layer and your domain.
|
||||
|
||||
```elixir
|
||||
# Generate with: mix phx.gen.context Catalog Product products title:string price:decimal
|
||||
defmodule MyApp.Catalog do
|
||||
import Ecto.Query
|
||||
alias MyApp.Repo
|
||||
alias MyApp.Catalog.Product
|
||||
|
||||
def list_products do
|
||||
Repo.all(Product)
|
||||
end
|
||||
|
||||
def get_product!(id), do: Repo.get!(Product, id)
|
||||
|
||||
def create_product(attrs \\ %{}) do
|
||||
%Product{}
|
||||
|> Product.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_product(%Product{} = product, attrs) do
|
||||
product
|
||||
|> Product.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_product(%Product{} = product) do
|
||||
Repo.delete(product)
|
||||
end
|
||||
|
||||
def change_product(%Product{} = product, attrs \\ %{}) do
|
||||
Product.changeset(product, attrs)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Context design principles:**
|
||||
- One context per bounded domain (Catalog, Accounts, Orders)
|
||||
- Contexts own their schemas — other contexts reference by ID, not struct
|
||||
- Cross-context calls go through the public context API, never access another context's Repo directly
|
||||
- Contexts can nest related schemas (Comments under Posts)
|
||||
|
||||
---
|
||||
|
||||
## Authentication — `mix phx.gen.auth`
|
||||
|
||||
```bash
|
||||
mix phx.gen.auth Accounts User users
|
||||
```
|
||||
|
||||
Phoenix 1.8 generates:
|
||||
- **Magic links** (passwordless) — email-based login links
|
||||
- **"Sudo mode"** — re-authentication for sensitive actions
|
||||
- Session-based auth with secure token handling
|
||||
- Email confirmation and password reset flows
|
||||
- `require_authenticated_user` plug for protected routes
|
||||
|
||||
## Scopes (New in 1.8)
|
||||
|
||||
Scopes make secure data access the default in generators. When you generate resources with a scope, all queries are automatically filtered by the scoped user.
|
||||
|
||||
```bash
|
||||
mix phx.gen.live Posts Post posts title body:text --scope current_user
|
||||
```
|
||||
|
||||
This generates code that automatically passes `current_user` to context functions, ensuring users only see their own data.
|
||||
|
||||
---
|
||||
|
||||
## Ecto — Database Layer
|
||||
|
||||
```elixir
|
||||
# Schema
|
||||
defmodule MyApp.Catalog.Product do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "products" do
|
||||
field :title, :string
|
||||
field :price, :decimal
|
||||
field :status, Ecto.Enum, values: [:draft, :published, :archived]
|
||||
has_many :reviews, MyApp.Reviews.Review
|
||||
belongs_to :category, MyApp.Catalog.Category
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(product, attrs) do
|
||||
product
|
||||
|> cast(attrs, [:title, :price, :status, :category_id])
|
||||
|> validate_required([:title, :price])
|
||||
|> validate_number(:price, greater_than: 0)
|
||||
|> unique_constraint(:title)
|
||||
|> foreign_key_constraint(:category_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Queries
|
||||
import Ecto.Query
|
||||
|
||||
# Composable queries
|
||||
def published(query \\ Product) do
|
||||
from p in query, where: p.status == :published
|
||||
end
|
||||
|
||||
def recent(query, days \\ 7) do
|
||||
from p in query, where: p.inserted_at > ago(^days, "day")
|
||||
end
|
||||
|
||||
def with_reviews(query) do
|
||||
from p in query, preload: [:reviews]
|
||||
end
|
||||
|
||||
# Usage: Product |> published() |> recent(30) |> with_reviews() |> Repo.all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telemetry — Built-in Observability
|
||||
|
||||
Phoenix 1.8 includes a Telemetry supervisor that tracks request duration, Ecto query times, and VM metrics out of the box.
|
||||
|
||||
```elixir
|
||||
# lib/my_app_web/telemetry.ex (auto-generated)
|
||||
defmodule MyAppWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def metrics do
|
||||
[
|
||||
summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
|
||||
summary("phoenix.router_dispatch.stop.duration", tags: [:route], unit: {:native, :millisecond}),
|
||||
summary("my_app.repo.query.total_time", unit: {:native, :millisecond}),
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Integrates with **PromEx** for Prometheus/Grafana dashboards (see Part 4: Ecosystem).
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**Never pass untrusted input to:** `Code.eval_string/3`, `:os.cmd/2`, `System.cmd/3`, `System.shell/2`, `:erlang.binary_to_term/2`
|
||||
|
||||
**Ecto prevents SQL injection by default** — the query DSL parameterizes all inputs. Only `Ecto.Adapters.SQL.query/4` with raw string interpolation is vulnerable.
|
||||
|
||||
**Safe deserialization:**
|
||||
```elixir
|
||||
# UNSAFE — even with :safe flag
|
||||
:erlang.binary_to_term(user_input, [:safe])
|
||||
|
||||
# SAFE — prevents executable terms
|
||||
Plug.Crypto.non_executable_binary_to_term(user_input, [:safe])
|
||||
```
|
||||
|
||||
**CSRF protection** is built into the `:browser` pipeline via `protect_from_forgery`. **Content Security Policy** is set by `put_secure_browser_headers`.
|
||||
|
||||
---
|
||||
|
||||
## Testing Phoenix
|
||||
|
||||
```elixir
|
||||
# Controller test
|
||||
defmodule MyAppWeb.ProductControllerTest do
|
||||
use MyAppWeb.ConnCase
|
||||
|
||||
test "GET /products", %{conn: conn} do
|
||||
conn = get(conn, ~p"/products")
|
||||
assert html_response(conn, 200) =~ "Products"
|
||||
end
|
||||
|
||||
test "POST /products creates product", %{conn: conn} do
|
||||
conn = post(conn, ~p"/products", product: %{title: "Widget", price: 9.99})
|
||||
assert redirected_to(conn) =~ ~p"/products/"
|
||||
end
|
||||
end
|
||||
|
||||
# LiveView test
|
||||
defmodule MyAppWeb.CounterLiveTest do
|
||||
use MyAppWeb.ConnCase
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "increments counter", %{conn: conn} do
|
||||
{:ok, view, html} = live(conn, ~p"/counter")
|
||||
assert html =~ "Count: 0"
|
||||
|
||||
assert view
|
||||
|> element("button", "+1")
|
||||
|> render_click() =~ "Count: 1"
|
||||
end
|
||||
end
|
||||
|
||||
# Channel test
|
||||
defmodule MyAppWeb.RoomChannelTest do
|
||||
use MyAppWeb.ChannelCase
|
||||
|
||||
test "broadcasts new messages" do
|
||||
{:ok, _, socket} = subscribe_and_join(socket(MyAppWeb.UserSocket), MyAppWeb.RoomChannel, "room:lobby")
|
||||
|
||||
push(socket, "new_msg", %{"body" => "hello"})
|
||||
assert_broadcast "new_msg", %{body: "hello"}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generators Cheat Sheet
|
||||
|
||||
```bash
|
||||
# HTML CRUD (controller + views + templates + context + schema + migration)
|
||||
mix phx.gen.html Catalog Product products title:string price:decimal
|
||||
|
||||
# LiveView CRUD
|
||||
mix phx.gen.live Catalog Product products title:string price:decimal
|
||||
|
||||
# JSON API
|
||||
mix phx.gen.json Catalog Product products title:string price:decimal
|
||||
|
||||
# Context + schema only (no web layer)
|
||||
mix phx.gen.context Catalog Product products title:string price:decimal
|
||||
|
||||
# Schema + migration only
|
||||
mix phx.gen.schema Product products title:string price:decimal
|
||||
|
||||
# Authentication
|
||||
mix phx.gen.auth Accounts User users
|
||||
|
||||
# Channel
|
||||
mix phx.gen.channel Room
|
||||
|
||||
# Presence
|
||||
mix phx.gen.presence
|
||||
|
||||
# Release files (Dockerfile, release.ex, overlay scripts)
|
||||
mix phx.gen.release
|
||||
mix phx.gen.release --docker # Include Dockerfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Release & Deployment
|
||||
|
||||
```bash
|
||||
# Generate release infrastructure
|
||||
mix phx.gen.release --docker
|
||||
|
||||
# Build release
|
||||
MIX_ENV=prod mix deps.get --only prod
|
||||
MIX_ENV=prod mix compile
|
||||
MIX_ENV=prod mix assets.deploy
|
||||
MIX_ENV=prod mix release
|
||||
|
||||
# Release commands
|
||||
_build/prod/rel/my_app/bin/server # Start with Phoenix server
|
||||
_build/prod/rel/my_app/bin/migrate # Run migrations
|
||||
_build/prod/rel/my_app/bin/my_app remote # Attach IEx console
|
||||
```
|
||||
|
||||
**Runtime config (`config/runtime.exs`):**
|
||||
```elixir
|
||||
import Config
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url = System.fetch_env!("DATABASE_URL")
|
||||
secret_key_base = System.fetch_env!("SECRET_KEY_BASE")
|
||||
|
||||
config :my_app, MyApp.Repo,
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
||||
|
||||
config :my_app, MyAppWeb.Endpoint,
|
||||
url: [host: System.fetch_env!("PHX_HOST"), port: 443, scheme: "https"],
|
||||
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
|
||||
secret_key_base: secret_key_base
|
||||
end
|
||||
```
|
||||
514
elixir/elixir-part4-ecosystem.md
Normal file
514
elixir/elixir-part4-ecosystem.md
Normal file
@ -0,0 +1,514 @@
|
||||
# Elixir Part 4: Ecosystem, Production & Deployment
|
||||
|
||||
## Ash Framework — Declarative Resource Modeling
|
||||
|
||||
For production/substantial projects, Ash provides a declarative, resource-oriented approach that eliminates boilerplate while remaining extensible.
|
||||
|
||||
```elixir
|
||||
# mix.exs deps
|
||||
{:ash, "~> 3.0"},
|
||||
{:ash_postgres, "~> 2.0"}, # Ecto/Postgres data layer
|
||||
{:ash_phoenix, "~> 2.0"}, # Phoenix integration
|
||||
{:ash_graphql, "~> 1.0"}, # Auto-generated GraphQL API
|
||||
{:ash_json_api, "~> 1.0"}, # Auto-generated JSON:API
|
||||
```
|
||||
|
||||
### Resource Definition
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Blog.Post do
|
||||
use Ash.Resource,
|
||||
domain: MyApp.Blog,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "posts"
|
||||
repo MyApp.Repo
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :title, :string, allow_nil?: false
|
||||
attribute :body, :string, allow_nil?: false
|
||||
attribute :status, :atom, constraints: [one_of: [:draft, :published, :archived]], default: :draft
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, MyApp.Accounts.User
|
||||
has_many :comments, MyApp.Blog.Comment
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
accept [:title, :body]
|
||||
change relate_actor(:author)
|
||||
end
|
||||
|
||||
update :publish do
|
||||
change set_attribute(:status, :published)
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action_type([:create, :update, :destroy]) do
|
||||
authorize_if actor_attribute_equals(:role, :admin)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Domain Definition
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Blog do
|
||||
use Ash.Domain
|
||||
|
||||
resources do
|
||||
resource MyApp.Blog.Post
|
||||
resource MyApp.Blog.Comment
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Using Ash Resources
|
||||
|
||||
```elixir
|
||||
# Create
|
||||
MyApp.Blog.Post
|
||||
|> Ash.Changeset.for_create(:create, %{title: "Hello", body: "World"}, actor: current_user)
|
||||
|> Ash.create!()
|
||||
|
||||
# Read with filters
|
||||
MyApp.Blog.Post
|
||||
|> Ash.Query.filter(status == :published)
|
||||
|> Ash.Query.sort(inserted_at: :desc)
|
||||
|> Ash.read!()
|
||||
|
||||
# Custom action
|
||||
post |> Ash.Changeset.for_update(:publish) |> Ash.update!()
|
||||
```
|
||||
|
||||
**When to use Ash vs plain Phoenix:** Ash excels when you need consistent authorization, multi-tenancy, auto-generated APIs, or complex domain logic. For simple CRUD apps or learning, plain Phoenix contexts are fine.
|
||||
|
||||
---
|
||||
|
||||
## Jido — Multi-Agent Orchestration Framework
|
||||
|
||||
Jido (~> 2.1) is the premier Elixir framework for building and managing agents of all types — not just LLMs. Vision: 10,000 agents per user on the BEAM.
|
||||
|
||||
```elixir
|
||||
# mix.exs deps
|
||||
{:jido, "~> 2.1"},
|
||||
{:jido_ai, "~> 0.x"}, # AI/LLM integration
|
||||
{:jido_action, "~> 0.x"}, # Composable actions
|
||||
{:jido_signal, "~> 0.x"}, # CloudEvents-based messaging
|
||||
```
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Agents are pure functional structs** — immutable, no side effects in the agent itself. Side effects are described as Directives.
|
||||
|
||||
```elixir
|
||||
defmodule MyAgent do
|
||||
use Jido.Agent,
|
||||
name: "my_agent",
|
||||
description: "Does useful things",
|
||||
actions: [FetchData, ProcessData, NotifyUser],
|
||||
schema: [
|
||||
model: [type: :string, default: "gpt-4"],
|
||||
temperature: [type: :float, default: 0.7]
|
||||
]
|
||||
end
|
||||
|
||||
# Create and command
|
||||
{:ok, agent} = MyAgent.new()
|
||||
{agent, directives} = MyAgent.cmd(agent, %Signal{type: "task.assigned", data: %{task: "analyze"}})
|
||||
# directives are typed effects: Emit, Spawn, Schedule, Stop
|
||||
```
|
||||
|
||||
### Actions — Composable Units
|
||||
|
||||
Actions are the building blocks. Each has a schema, validates input, and returns output.
|
||||
|
||||
```elixir
|
||||
defmodule FetchData do
|
||||
use Jido.Action,
|
||||
name: "fetch_data",
|
||||
description: "Fetches data from an API",
|
||||
schema: [
|
||||
url: [type: :string, required: true],
|
||||
timeout: [type: :integer, default: 5000]
|
||||
]
|
||||
|
||||
@impl true
|
||||
def run(params, _context) do
|
||||
case Req.get(params.url, receive_timeout: params.timeout) do
|
||||
{:ok, %{status: 200, body: body}} -> {:ok, %{data: body}}
|
||||
{:ok, %{status: status}} -> {:error, "HTTP #{status}"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Actions expose themselves as AI tools
|
||||
FetchData.to_tool() # Returns tool spec for LLM function calling
|
||||
```
|
||||
|
||||
### Signals — CloudEvents v1.0.2 Messaging
|
||||
|
||||
```elixir
|
||||
signal = %Jido.Signal{
|
||||
type: "task.completed",
|
||||
source: "agent:worker_1",
|
||||
data: %{result: "analysis complete"},
|
||||
subject: "task:123"
|
||||
}
|
||||
```
|
||||
|
||||
### Directives — Typed Effect Descriptions
|
||||
|
||||
Agents don't perform side effects directly. They return Directives describing what should happen:
|
||||
|
||||
| Directive | Effect |
|
||||
|-----------|--------|
|
||||
| `Emit` | Send a signal to another agent/system |
|
||||
| `Spawn` | Create a new child agent |
|
||||
| `Schedule` | Schedule a future action |
|
||||
| `Stop` | Terminate the agent |
|
||||
|
||||
### AgentServer — Runtime Process
|
||||
|
||||
`AgentServer` wraps an agent in a GenServer for real-time operation:
|
||||
|
||||
```elixir
|
||||
{:ok, pid} = Jido.AgentServer.start_link(agent: MyAgent, id: "worker-1")
|
||||
Jido.AgentServer.signal(pid, %Signal{type: "task.start", data: %{...}})
|
||||
```
|
||||
|
||||
### When to Use Jido
|
||||
|
||||
- Multi-agent systems where agents communicate via signals
|
||||
- LLM-powered agents that need tool calling (via `to_tool/0`)
|
||||
- Systems requiring parent-child agent hierarchies
|
||||
- Workflows with complex state machines (Jido includes FSM strategy)
|
||||
- Any scenario targeting high agent density (thousands per node)
|
||||
|
||||
---
|
||||
|
||||
## OTP Releases & Docker
|
||||
|
||||
### OTP Release
|
||||
|
||||
```elixir
|
||||
# mix.exs
|
||||
def project do
|
||||
[
|
||||
app: :my_app,
|
||||
releases: [
|
||||
my_app: [
|
||||
include_executables_for: [:unix],
|
||||
steps: [:assemble, :tar]
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
### Docker Multistage Build
|
||||
|
||||
```dockerfile
|
||||
# Build stage
|
||||
FROM hexpm/elixir:1.19.5-erlang-27.3-debian-bookworm-20240904 AS build
|
||||
RUN apt-get update && apt-get install -y build-essential git
|
||||
WORKDIR /app
|
||||
ENV MIX_ENV=prod
|
||||
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV && mix deps.compile
|
||||
|
||||
COPY config/config.exs config/prod.exs config/
|
||||
COPY lib lib
|
||||
COPY priv priv
|
||||
COPY assets assets
|
||||
|
||||
RUN mix assets.deploy && mix compile && mix release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y libstdc++6 openssl libncurses5 locales
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/_build/prod/rel/my_app ./
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:4000/health || exit 1
|
||||
|
||||
CMD ["bin/server"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distributed Erlang & Clustering
|
||||
|
||||
```elixir
|
||||
# libcluster config (config/runtime.exs)
|
||||
config :libcluster,
|
||||
topologies: [
|
||||
local: [
|
||||
strategy: Cluster.Strategy.Gossip
|
||||
],
|
||||
# OR for Docker/K8s:
|
||||
k8s: [
|
||||
strategy: Cluster.Strategy.Kubernetes,
|
||||
config: [
|
||||
mode: :hostname,
|
||||
kubernetes_namespace: "default",
|
||||
kubernetes_selector: "app=my_app"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
# Distributed PubSub (built into Phoenix)
|
||||
Phoenix.PubSub.broadcast(MyApp.PubSub, "events", {:new_order, order})
|
||||
Phoenix.PubSub.subscribe(MyApp.PubSub, "events")
|
||||
|
||||
# Horde for distributed supervisor/registry
|
||||
{:horde, "~> 0.9"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### PromEx — Prometheus Metrics for Elixir
|
||||
|
||||
```elixir
|
||||
# mix.exs
|
||||
{:prom_ex, "~> 1.9"}
|
||||
|
||||
# lib/my_app/prom_ex.ex
|
||||
defmodule MyApp.PromEx do
|
||||
use PromEx, otp_app: :my_app
|
||||
|
||||
@impl true
|
||||
def plugins do
|
||||
[
|
||||
PromEx.Plugins.Application,
|
||||
PromEx.Plugins.Beam,
|
||||
{PromEx.Plugins.Phoenix, router: MyAppWeb.Router, endpoint: MyAppWeb.Endpoint},
|
||||
{PromEx.Plugins.Ecto, repos: [MyApp.Repo]},
|
||||
PromEx.Plugins.Oban
|
||||
]
|
||||
end
|
||||
|
||||
@impl true
|
||||
def dashboards do
|
||||
[
|
||||
{:prom_ex, "application.json"},
|
||||
{:prom_ex, "beam.json"},
|
||||
{:prom_ex, "phoenix.json"},
|
||||
{:prom_ex, "ecto.json"}
|
||||
]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Full Observability Stack (BEAMOps)
|
||||
|
||||
From "Engineering Elixir Applications":
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **PromEx** | Expose BEAM/Phoenix/Ecto metrics as Prometheus endpoints |
|
||||
| **Prometheus** | Scrape and store time-series metrics |
|
||||
| **Grafana** | Dashboards and alerting |
|
||||
| **Loki** | Log aggregation (like Prometheus but for logs) |
|
||||
| **Promtail/Alloy** | Ship logs from containers to Loki |
|
||||
|
||||
**Health checks + automatic rollback:** Docker `HEALTHCHECK` triggers rollback if the new container fails health checks within the start period. Use `docker system prune` automation to prevent disk bloat.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
name: Elixir CI
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports: ['5432:5432']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: erlef/setup-beam@v1
|
||||
with:
|
||||
elixir-version: '1.19.5'
|
||||
otp-version: '27.3'
|
||||
- run: mix deps.get
|
||||
- run: mix compile --warnings-as-errors
|
||||
- run: mix format --check-formatted
|
||||
- run: mix credo --strict
|
||||
- run: mix test
|
||||
- run: mix deps.unlock --check-unused
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interop — Elixir as Orchestrator
|
||||
|
||||
### Python via Ports/Erlport
|
||||
```elixir
|
||||
# Using erlport for bidirectional Python calls
|
||||
{:ok, pid} = :python.start([{:python_path, ~c"./python_scripts"}])
|
||||
result = :python.call(pid, :my_module, :my_function, [arg1, arg2])
|
||||
:python.stop(pid)
|
||||
```
|
||||
|
||||
### Rust via Rustler NIFs
|
||||
```elixir
|
||||
# mix.exs
|
||||
{:rustler, "~> 0.34"}
|
||||
|
||||
# lib/my_nif.ex
|
||||
defmodule MyApp.NativeSort do
|
||||
use Rustler, otp_app: :my_app, crate: "native_sort"
|
||||
|
||||
# NIF stubs — replaced at load time by Rust implementations
|
||||
def sort(_list), do: :erlang.nif_error(:nif_not_loaded)
|
||||
end
|
||||
```
|
||||
|
||||
### System Commands via Ports
|
||||
```elixir
|
||||
# One-shot command
|
||||
{output, 0} = System.cmd("ffmpeg", ["-i", input, "-o", output])
|
||||
|
||||
# Long-running port
|
||||
port = Port.open({:spawn, "python3 worker.py"}, [:binary, :exit_status])
|
||||
send(port, {self(), {:command, "process\n"}})
|
||||
receive do
|
||||
{^port, {:data, data}} -> handle_response(data)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cortex Deployment Story
|
||||
|
||||
### Current State
|
||||
- Cortex runs Ubuntu 24.04 with Caddy as web server
|
||||
- Elixir is **not yet installed** — needs `asdf` or direct install
|
||||
- Docker is **planned but not installed** — needed for containerized Elixir apps
|
||||
- Symbiont (Python) is the current orchestrator — Elixir will gradually take over
|
||||
|
||||
### Installation Plan for Cortex
|
||||
```bash
|
||||
# Option 1: asdf (recommended — manages multiple versions)
|
||||
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
|
||||
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
|
||||
asdf plugin add erlang
|
||||
asdf plugin add elixir
|
||||
asdf install erlang 27.3
|
||||
asdf install elixir 1.19.5-otp-27
|
||||
asdf global erlang 27.3
|
||||
asdf global elixir 1.19.5-otp-27
|
||||
|
||||
# Option 2: Direct from Erlang Solutions
|
||||
wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
|
||||
dpkg -i erlang-solutions_2.0_all.deb
|
||||
apt-get update && apt-get install esl-erlang elixir
|
||||
```
|
||||
|
||||
### BEAMOps Principles for Cortex
|
||||
|
||||
From "Engineering Elixir Applications" — the deployment philosophy:
|
||||
|
||||
1. **Environment Integrity** — identical builds dev/staging/prod via Docker + releases
|
||||
2. **Infrastructure as Code** — Caddy config, systemd units, backup scripts all version-controlled
|
||||
3. **OTP Releases** — self-contained, no runtime deps, `bin/my_app start`
|
||||
4. **Distributed Erlang** — nodes discover each other, share state via PubSub, global registry
|
||||
5. **Instrumentation** — PromEx + Prometheus + Grafana + Loki for full observability
|
||||
6. **Health Checks + Rollbacks** — Docker health checks trigger automatic rollback on failed deploys
|
||||
7. **Zero-downtime deploys** — rolling updates via Docker Swarm or `mix release` hot upgrades
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Process Anti-Patterns
|
||||
- **GenServer as code organization** — don't wrap pure functions in a GenServer. Use modules.
|
||||
- **Agent for complex state** — if you need more than get/update, use GenServer directly.
|
||||
- **Spawning unsupervised processes** — always use `Task.Supervisor` or link to a supervisor.
|
||||
|
||||
### Code Anti-Patterns
|
||||
- **Primitive obsession** — use structs, not bare maps, for domain concepts.
|
||||
- **Boolean parameters** — use atoms or keyword options: `format: :json` not `json: true`.
|
||||
- **Large modules** — split by concern, not by entity type. Domain logic, web layer, workers.
|
||||
- **String keys in internal maps** — use atoms internally, strings only at boundaries (JSON, forms).
|
||||
|
||||
### Design Anti-Patterns
|
||||
- **Monolithic contexts** — Phoenix contexts should be small, focused. Split `Accounts` from `Authentication`.
|
||||
- **God GenServer** — one process handling all state for the app. Distribute responsibility.
|
||||
- **Synchronous calls to slow services** — use `Task.async` + `Task.await` with timeouts.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
### HTTP Request with Req
|
||||
```elixir
|
||||
Req.get!("https://api.example.com/data",
|
||||
headers: [{"authorization", "Bearer #{token}"}],
|
||||
receive_timeout: 15_000
|
||||
).body
|
||||
```
|
||||
|
||||
### JSON Encode/Decode
|
||||
```elixir
|
||||
Jason.encode!(%{name: "Michael", role: :admin})
|
||||
Jason.decode!(~s({"name": "Michael"}), keys: :atoms)
|
||||
```
|
||||
|
||||
### Background Job with Oban
|
||||
```elixir
|
||||
defmodule MyApp.Workers.EmailWorker do
|
||||
use Oban.Worker, queue: :mailers, max_attempts: 3
|
||||
|
||||
@impl true
|
||||
def perform(%Oban.Job{args: %{"to" => to, "template" => template}}) do
|
||||
MyApp.Mailer.send(to, template)
|
||||
end
|
||||
end
|
||||
|
||||
# Enqueue
|
||||
%{to: "user@example.com", template: "welcome"}
|
||||
|> MyApp.Workers.EmailWorker.new(schedule_in: 60)
|
||||
|> Oban.insert!()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Elixir Official Docs](https://hexdocs.pm/elixir/) — always check 1.19.5 version
|
||||
- [Ash Framework Docs](https://hexdocs.pm/ash/) — resource-oriented patterns
|
||||
- [Phoenix HexDocs](https://hexdocs.pm/phoenix/) — web framework
|
||||
- [Jido Docs](https://hexdocs.pm/jido/) — multi-agent orchestration
|
||||
- [Elixir Forum](https://elixirforum.com/) — community Q&A
|
||||
- "Elixir in Action" by Saša Jurić — deep BEAM/OTP understanding (note: covers 1.15, check breaking changes)
|
||||
- "Engineering Elixir Applications" by Fairholm & D'Lacoste — BEAMOps deployment patterns
|
||||
Loading…
Reference in New Issue
Block a user