Skip to main content

loom-elixir

Elixir language expertise for idiomatic, production-quality code.

Datos de origen

Repositorio
cosmix/loom
Última actividad en el origen
25 de septiembre de 2026 a las 06:00
Idioma detectado de SKILL.md
inglés
Estrellas
61
Forks
4

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
loom-elixir
description
Elixir language expertise for idiomatic, production-quality code.
triggers
["elixir","ex","exs","mix","hex","otp","erlang","beam","genserver","supervisor","phoenix","liveview","ecto","exunit","credo","dialyzer","oban","plug","mox"]
# Elixir Language Expertise ## Overview Idiomatic, production-grade Elixir on the BEAM: mix, pattern matching, processes and OTP (GenServer, supervisors), Phoenix and Ecto, and ExUnit. Assumes competence; the value is in what is easy to get wrong (atom exhaustion, a single GenServer serializing the hot path, unbounded mailboxes, compile-time config baked into releases, LiveView's double mount, Ecto's absent lazy loading) and in writing tests that loom's `mix-test` adapter can select one at a time. ## Tooling **mix** builds, tests and manages dependencies; Hex is the package registry. | Task | Command | | --- | --- | | New project | `mix new app --sup` (with a supervision tree), `mix phx.new app` (Phoenix) | | Add a dependency | add it to `deps/0` in `mix.exs` (mix has no `add` command), then `mix deps.get` to resolve and write `mix.lock` | | Update one dependency | `mix deps.update jason` | | Remove unused lock entries | `mix deps.unlock --unused`; CI check: `mix deps.unlock --check-unused` | | Outdated / retired packages | `mix hex.outdated`, `mix hex.audit`; `mix deps.audit` with `mix_audit` for CVEs | | Compile strictly | `mix compile --warnings-as-errors` | | Find recompilation chains | `mix xref graph --label compile-connected` | | Format | `mix format` (config in `.formatter.exs`); CI: `mix format --check-formatted` | | Lint | `mix credo --strict` | | Types | `mix dialyzer` (dialyxir); recent compilers also emit gradual type-check warnings | | Security (Phoenix) | `mix sobelow` | Commit `mix.lock`. Dependencies with the `only: [:dev, :test]` and `runtime: false` options stay out of releases. **The gate:** ```bash mix format --check-formatted mix compile --warnings-as-errors mix credo --strict mix test --warnings-as-errors mix dialyzer # when the project uses dialyxir ``` ## Pattern Matching and Control Flow - `=` matches; it binds on success and raises `MatchError` on failure. Pin an existing value with `^`: `^expected = actual`. - Prefer multiple function clauses with guards over `if`/`cond` chains inside one body; clause order is match order. - Return tagged tuples (`{:ok, value}`, `{:error, reason}`) from functions that can fail; the bang variant (`File.read!`) raises instead. Offer both when callers need both. - `with` chains the happy path. Without `else`, the first non-matching value is returned as is. With `else`, every non-matching shape must have a clause, or `with` raises `WithClauseError`. - Map patterns match a subset of keys: `%{} = value` matches any map, so test emptiness with `map_size(m) == 0`. A struct pattern `%User{}` also asserts the struct type. - Binary patterns parse protocols and file headers without manual slicing. ```elixir defmodule Spool.Writer do @spec write(Path.t(), String.t(), iodata()) :: :ok | {:error, :symlink | File.posix()} def write(root, job_id, payload) when is_binary(job_id) do with {:ok, %File.Stat{type: :directory}} <- File.lstat(root), :ok <- File.write(Path.join(root, job_id), payload, [:exclusive]) do :ok else {:ok, %File.Stat{type: :symlink}} -> {:error, :symlink} {:ok, %File.Stat{}} -> {:error, :enotdir} {:error, reason} -> {:error, reason} end end end <<magic::binary-size(4), version::unsigned-big-16, rest::binary>> = header ``` **Atoms are never garbage-collected** and the atom table has a fixed limit (1,048,576 by default); filling it crashes the VM. Never call `String.to_atom/1` or `:erlang.binary_to_term/1` on external input. Use `String.to_existing_atom/1` or an explicit map from strings to atoms. **Structs and data:** `@enforce_keys` for required fields; `Keyword.validate!/2` for option lists; `get_in`/`put_in`/`update_in` for nested updates; `Map.update!/3` when the key must exist. ## Processes and OTP - Processes share nothing and communicate by message; each is cheap (a few KB). Every message is copied into the receiver's heap, except binaries over 64 bytes, which are reference-counted. - **GenServer** state lives in one process, so every call is serialized. Use a GenServer for state, concurrency control or lifecycle, and plain modules for code organization. A single GenServer on the hot path becomes the bottleneck; serve read-heavy shared state from ETS (`read_concurrency: true`) or `:persistent_term` (rarely changed: every update triggers a global GC pass). - Do slow initialization in `handle_continue/2` so `start_link` returns and the supervisor is not blocked. - `GenServer.call` times out after 5_000 ms by default and exits the caller. `cast` has no backpressure: a fast producer grows the receiver's mailbox without bound. Prefer `call` when the sender can outpace the receiver. - Defining any `handle_info/2` clause replaces the default, which logs unexpected messages; add a catch-all clause, or a stray message crashes the process with `FunctionClauseError`. - `terminate/2` runs on shutdown only if the process traps exits (`Process.flag(:trap_exit, true)`); never rely on it for durable cleanup. - Links propagate crashes; monitors deliver a `:DOWN` message. Link what should die together; monitor what you only observe. - Name dynamic processes through `Registry` (`{:via, Registry, {Spool.Registry, key}}`); never generate atoms for names. ```elixir defmodule Spool.Queue do use GenServer require Logger def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) def push(job), do: GenServer.call(__MODULE__, {:push, job}) def depth, do: GenServer.call(__MODULE__, :depth) @impl true def init(opts), do: {:ok, %{jobs: :queue.new(), dir: Keyword.fetch!(opts, :dir)}, {:continue, :load}} @impl true def handle_continue(:load, state), do: {:noreply, %{state | jobs: load_pending(state.dir)}} @impl true def handle_call({:push, job}, _from, state), do: {:reply, :ok, %{state | jobs: :queue.in(job, state.jobs)}} def handle_call(:depth, _from, state), do: {:reply, :queue.len(state.jobs), state} @impl true def handle_info(msg, state) do Logger.warning("unexpected message: #{inspect(msg)}") {:noreply, state} end end ``` **Supervisors.** Strategies: `:one_for_one` (restart the failed child), `:rest_for_one` (restart it and every child started after it), `:one_for_all`. Children start in list order and stop in reverse. The default restart intensity is 3 restarts in 5 seconds; past it the supervisor exits and its parent decides. Use `restart: :transient` for children that may finish normally, `DynamicSupervisor` for children started on demand. ```elixir children = [ {Registry, keys: :unique, name: Spool.Registry}, {Task.Supervisor, name: Spool.TaskSupervisor}, {Spool.Queue, dir: Application.fetch_env!(:spool, :dir)} ] Supervisor.start_link(children, strategy: :rest_for_one, name: Spool.Supervisor) ``` **Tasks.** `Task.async_stream(items, &fetch/1, max_concurrency: 8, timeout: 10_000)` bounds concurrency; `Task.Supervisor.async_nolink/2` runs work that may crash without taking the caller down. A bare `Task.async` links to the caller, so its crash kills the caller too. ## Phoenix Essentials - **Contexts** (`Accounts`, `Billing`) own their schemas and `Repo` calls; controllers and LiveViews call contexts, never `Repo` directly. - **Plug:** `conn` is immutable; each plug returns a new one. A plug that sends a response must `halt/1`, or later plugs keep running. - **LiveView** `mount/3` runs twice: once for the disconnected HTTP render, once over the WebSocket. Subscribe to PubSub and start timers only when `connected?(socket)`. Use `stream/3` for large collections so the socket does not hold them, and `assign_async/3` or `start_async/3` for slow loads. - **Verified routes** (`~p"/orders/#{order}"`) are checked at compile time. - **Config:** `config/config.exs` and `config/prod.exs` are evaluated at build time and baked into the release; read secrets and environment in `config/runtime.exs` with `System.fetch_env!/1`. Inside modules, `Application.compile_env/3` for build-time values (mix tracks them), `Application.get_env/3` at runtime. - **Security:** HEEx escapes output; `raw/1` bypasses it. Forms carry CSRF tokens by default; keep `:protect_from_forgery` and `:put_secure_browser_headers` in the browser pipeline. ```elixir def mount(_params, _session, socket) do if connected?(socket), do: Phoenix.PubSub.subscribe(Shop.PubSub, "orders") {:ok, stream(socket, :orders, Orders.list_recent())} end def handle_info({:order_created, order}, socket) do {:noreply, stream_insert(socket, :orders, order, at: 0)} end ``` ## Ecto Essentials - **Changesets:** the field list passed to `cast/4` is the mass-assignment allowlist. `unique_constraint/2` turns the database's unique-index violation into a changeset error and needs that index to exist. - **No lazy loading:** an association that was not preloaded is `%Ecto.Association.NotLoaded{}`. Preload in the query or with `Repo.preload/2`; preloading inside a loop is N+1. - **Queries** compose, and `^` interpolates a bound parameter, so pinned values cannot inject SQL. `fragment/1` takes `?` placeholders and a literal string. - **Transactions:** `Repo.transaction/2` or `Ecto.Multi` for named steps whose failure you want to inspect. - **Bulk operations:** `Repo.insert_all/3` and `update_all/3` skip changesets, validations and automatic timestamps. - **Migrations:** `create index(..., concurrently: true)` needs `@disable_ddl_transaction true` and `@disable_migration_lock true`. ```elixir def changeset(user, attrs) do user |> cast(attrs, [:email, :name]) # role is never cast from user input |> validate_required([:email]) |> update_change(:email, &String.downcase/1) |> unique_constraint(:email) end Order |> where([o], o.customer_id == ^customer_id) |> preload(:lines) |> Repo.all() ``` ## Background Jobs and Releases - **Oban** stores jobs in Postgres. Insert the job in the same transaction as the data it processes (`Ecto.Multi` plus `Oban.insert/3`), so a rollback never leaves an orphan job. - `perform/1` returns `:ok`, `{:ok, value}`, `{:error, reason}` (retried up to `max_attempts`), `{:cancel, reason}` or `{:snooze, seconds}`. A raise also counts as a failed attempt. - Job args round-trip through JSON: a worker receives string keys (`%{"order_id" => id}`), so match on strings, and pass ids instead of structs. - Workers must be idempotent: every failure is retried. Add `unique: [period: 60]` where duplicates are possible. - Test with `Oban.Testing` (`perform_job/2`, `assert_enqueued/1`) and `testing: :manual` in `config/test.exs`. ```elixir defmodule Shop.Workers.SendConfirmation do use Oban.Worker, queue: :mailers, max_attempts: 5 @impl Oban.Worker def perform(%Oban.Job{args: %{"order_id" => order_id}}) do with {:ok, order} <- Shop.Orders.fetch(order_id) do Shop.Mailer.deliver_confirmation(order) end end end ``` **Releases:** `MIX_ENV=prod mix release` builds a self-contained release; `config/runtime.exs` runs at every boot. Run migrations from a release module (`bin/shop eval "Shop.Release.migrate()"`), since mix is absent in a release. ## Testing ```elixir # test/spool/writer_test.exs defmodule Spool.WriterTest do use ExUnit.Case, async: true @moduletag :tmp_dir describe "write/3" do test "writes the payload into the spool directory", %{tmp_dir: dir} do assert :ok = Spool.Writer.write(dir, "job-1", "payload") assert File.read!(Path.join(dir, "job-1")) == "payload" end test "rejects a symlinked spool directory", %{tmp_dir: dir} do target = Path.join(dir, "target") link = Path.join(dir, "spool") File.mkdir_p!(target) File.ln_s!(target, link) assert {:error, :symlink} = Spool.Writer.write(link, "job-1", "payload") refute File.exists?(Path.join(target, "job-1")) end end end ``` - `test/test_helper.exs` calls `ExUnit.start()`; test files end in `_test.exs`. - `async: true` runs the module concurrently with other async modules. Leave it off when tests touch global state: application env, named processes, fixed file-system paths, `:persistent_term`. - `setup` returns a map or keyword list merged into the context; `setup_all` runs once per module; `on_exit/1` registers cleanup. The `:tmp_dir` tag gives each test a fresh directory in the context. - `start_supervised!/1` starts a process under the test supervisor and stops it when the test ends; prefer it over `start_link` in tests. - `assert_receive msg, timeout` (default 100 ms) and `refute_receive` replace `Process.sleep` for asynchronous assertions. - `assert_raise/2`, `capture_log/1`, `capture_io/1`; `doctest Spool.Writer` runs the `iex>` examples in `@doc`. - **Mox:** mocks only behaviours. `Mox.defmock(Spool.StoreMock, for: Spool.Store)`, `expect/4` in the test, `setup :verify_on_exit!`, and `Mox.allow/3` when another process calls the mock.
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub