Move skills to .agents/skills

This commit is contained in:
Claudio Ortolina
2026-05-05 07:16:54 +01:00
parent 41f4f08831
commit bcbfe2be75
19 changed files with 39 additions and 36 deletions
-152
View File
@@ -1,152 +0,0 @@
---
name: error-investigation
description: Use this skill when investigating, triaging, debugging, or responding to production errors. Use PROACTIVELY when the user mentions "error", "production error", "crash", "exception", "stacktrace", "error tracker", "ErrorTracker", "mute", "resolve", "triage", "investigate", "debug production", "what's happening in production", or asks about application health. Also use when fetch_production_errors or fetch_production_logs output is available and needs interpretation.
---
# Error Investigation
Systematic workflow for investigating and triaging production errors. The project
uses ErrorTracker with email notifications, mute support, and an ignorer for
non-actionable errors.
## Available Tools
| Tool | Purpose | When to Use |
|------|---------|------------|
| `fetch_production_errors` | List/filter errors | Starting point — browse recent errors |
| `fetch_production_error` | Full error details (stacktrace, occurrences, context) | Deep-dive into a specific error |
| `fetch_production_logs` | Recent server logs | Correlate errors with server activity |
## Investigation Workflow
### Step 1: Get an Overview
Start with `fetch_production_errors` with narrow filters:
```
fetch_production_errors(status: "unresolved", limit: 20)
```
Note the error IDs, reasons, and occurrence counts.
### Step 2: Prioritize
Rank errors by:
1. **Frequency** — errors with hundreds of occurrences need attention
2. **Recency** — is it still happening or was it a one-off?
3. **Impact** — does it affect user-facing features or is it background noise?
### Step 3: Deep Dive on Top Candidates
For each candidate, fetch full details:
```
fetch_production_error(id: 42)
```
Look at:
- **Stacktrace** — which module/function is failing?
- **Occurrences** — pattern over time? Spikes correlate with deployments?
- **Context** — what request params or user action triggered it?
- **Breadcrumbs** — what happened before the error?
### Step 4: Correlate with Logs
If the error is infrastructure-related (timeouts, connection issues), check logs:
```
fetch_production_logs(grep: "timeout", tail: 50)
```
### Step 5: Determine Action
| Error Type | Action |
|-----------|--------|
| Bot scanner (NoRouteError, wp-admin, .env, xmlrpc) | Already ignored by `ErrorIgnorer` — nothing to do |
| Expected transient (rate limit, timeout) | Monitor occurrence rate; if spiking, check API health |
| New bug | Create Backlog task, fix, deploy |
| Recurring non-critical | Mute if understood and not actionable |
| Resolved by recent deploy | Mark as resolved |
## ErrorIgnorer — What's Already Filtered
`MusicLibrary.ErrorIgnorer` implements `ErrorTracker.Ignorer` and filters non-actionable
errors. These never appear in the error list:
- `NoRouteError` from bot scanners (wp-admin, .env, xmlrpc, etc.)
- Other noise classified as non-actionable
If you see errors that look like bot traffic but aren't filtered, they may need to
be added to `ErrorIgnorer`.
## ErrorTracker.ErrorNotifier
The notifier is a GenServer that:
- Attaches to ErrorTracker telemetry events
- **Skips muted errors** — no email sent
- **Throttles repeated errors** — doesn't spam on recurring issues
- **Dispatches email** via `Swoosh.Adapters.Mailgun` when an error meets thresholds
### When to Mute
Mute errors that are:
- **Understood** — you know exactly what causes it
- **Non-actionable** — can't fix (e.g., third-party API sporadic failures)
- **Not impactful** — doesn't affect user experience
- **Frequent enough to be noisy** — would otherwise spam email
Muted errors still appear in the error list but are filtered by default and don't
trigger email notifications.
### When to Resolve
Mark as resolved when:
- A fix has been deployed and confirmed working
- The error was a one-off that won't recur
- The cause has been eliminated (e.g., config change, dependency update)
## Common Error Patterns
### External API Failures
```
%Req.TransportError{reason: :timeout}
%Req.TransportError{reason: :econnrefused}
```
→ Check if the API is down. These are transient and Oban workers retry automatically.
Monitor frequency — if spiking, the API may have an outage.
### Rate Limiting
```
%MusicLibrary.LastFm.API.ErrorResponse{kind: :rate_limit}
```
→ Workers snooze and retry. Check if rate limit intervals need adjustment.
### Database Issues
```
%SQLite3.Error{message: "database is locked"}
```
→ Check for concurrent write contention. `heavy_writes` queue has concurrency 1 to
prevent this. If happening on `default` queue, consider moving the worker.
### OpenAI Quota
```
%OpenAI.API.ErrorResponse{kind: :auth_error, body: %{"code" => "insufficient_quota"}}
```
→ This is a permanent error (`{:cancel, reason}`). Check OpenAI billing/quotas.
## Post-Fix Verification
After deploying a fix:
1. Watch `fetch_production_errors` for new occurrences
2. Once confirmed resolved, mark the error as resolved
3. If the fix involved a worker, check Oban Web for any cancelled jobs related to
the error
@@ -1,290 +0,0 @@
---
name: external-api-integration
description: Use this skill when adding, modifying, extending, or debugging ANY external API integration (MusicBrainz, Last.fm, Discogs, Wikipedia, OpenAI, BraveSearch, Mailgun). Use PROACTIVELY when the user mentions "API", "integration", "HTTP client", "rate limit", "Req", "MusicBrainz", "Last.fm", "Discogs", "Wikipedia", "OpenAI", "Brave", "webhook", "external service", "third-party API", "error response", or asks about API patterns. Also use when adding new API calls to existing integrations or creating worker modules for API operations.
---
# External API Integration
Project-specific patterns for external API integrations. Every integration follows a
consistent three-module architecture with rate limiting, structured error handling,
and test stubbing.
## Checklist Before Adding or Modifying an API Integration
1. **Does an `ErrorResponse` module exist?** Every API MUST have a
`<API>.API.ErrorResponse` module implementing `MusicLibrary.ErrorResponse` behaviour.
2. **Is rate limiting configured?** Every API uses `Req.RateLimiter` with a
per-API interval. Check the architecture doc for existing intervals.
3. **Are test stubs in place?** All HTTP calls must be stubbed via `config/test.exs`
using `Req.Test`. Use fixture modules for response data.
4. **Does the worker use `ErrorHandler`?** Workers that call APIs must route errors
through `MusicLibrary.Worker.ErrorHandler.to_oban_result/1`.
## Three-Module Architecture
Every external API integration follows this pattern:
```
lib/music_library/<service>/
├── <service>.ex # Facade: public API, delegates to API module
├── api.ex # API: Req HTTP client, rate-limited
├── api/
│ ├── config.ex # Config: NimbleOptions schema, reads from app env
│ ├── error_response.ex # ErrorResponse: implements MusicLibrary.ErrorResponse behaviour
│ └── fixtures.ex # Fixtures: test response data (for API-specific fixtures)
```
### Facade Module (`<Service>.ex`)
The public interface. No HTTP logic here — delegates to the API module:
```elixir
defmodule MusicLibrary.LastFm do
@spec get_recent_tracks(keyword()) :: {:ok, [LastFm.Track.t()]} | {:error, term()}
def get_recent_tracks(opts \\ []) do
LastFm.API.get_recent_tracks(opts)
end
end
```
### API Module (`api.ex`)
Req-based HTTP client. Uses `Req.RateLimiter` via the request pipeline:
```elixir
defmodule MusicLibrary.LastFm.API do
@spec get_recent_tracks(keyword()) :: {:ok, [LastFm.Track.t()]} | {:error, term()}
def get_recent_tracks(opts) do
config = LastFm.API.Config.new!(opts)
Req.new(
base_url: "https://ws.audioscrobbler.com/2.0",
params: [api_key: config.api_key, format: "json"],
plugins: [
{Req.RateLimiter, id: :last_fm}
]
)
|> Req.get(url: "/", params: [method: "user.getRecentTracks", user: config.user])
|> case do
{:ok, %{status: 200, body: body}} -> {:ok, parse_tracks(body)}
{:ok, %{status: status, body: body}} -> {:error, LastFm.API.ErrorResponse.new(status, body)}
{:error, error} -> {:error, error}
end
end
end
```
Key patterns:
- **Config goes first** — build it from opts, validate with NimbleOptions
- **Rate limiter as Req plugin** — `{Req.RateLimiter, id: :service_name}`
- **Non-200 status codes** are wrapped in `ErrorResponse` structs, not raw maps
- **Req-level errors** (timeouts, DNS) are passed through as-is
- **Successful responses** are parsed into domain structs
### Config Module (`api/config.ex`)
NimbleOptions schema, reads from application env:
```elixir
defmodule MusicLibrary.LastFm.API.Config do
@schema [
api_key: [type: :string, required: true],
shared_secret: [type: :string, required: false],
user: [type: :string, required: false]
]
def new!(opts) do
env = Application.get_env(:music_library, :last_fm, [])
opts
|> Keyword.merge(env)
|> NimbleOptions.validate!(@schema)
end
end
```
## ErrorResponse Behaviour
Every API must have an `ErrorResponse` module implementing:
```elixir
defmodule MusicLibrary.ErrorResponse do
@type t :: struct()
@callback retryable?(t()) :: boolean()
@callback retry_delay_seconds(t()) :: non_neg_integer() | nil
end
```
Example implementation:
```elixir
defmodule MusicLibrary.LastFm.API.ErrorResponse do
defstruct [:status, :body, :kind]
@behaviour MusicLibrary.ErrorResponse
def new(status, body) do
%__MODULE__{
status: status,
body: body,
kind: MusicLibrary.HttpError.kind(status) # baseline mapping
}
end
@impl true
def retryable?(%{kind: :rate_limit}), do: true # 429
def retryable?(%{kind: :server_error}), do: true # 5xx
def retryable?(_), do: false
@impl true
def retry_delay_seconds(%{status: 429, body: %{"error" => _, "message" => msg}}) do
MusicLibrary.RetryDelay.parse(msg) # parse Retry-After header
end
def retry_delay_seconds(_), do: nil
end
```
### HTTP Error Kind Mapping (`MusicLibrary.HttpError`)
Baseline mapping used by all APIs:
| HTTP status | Kind |
|-------------|------|
| 429 | `:rate_limit` |
| 5xx | `:server_error` |
| Timeout | `:timeout` |
| 401/403 | `:auth_error` |
| 404 | `:not_found` |
| 4xx | `:client_error` |
| Other | `:unknown` |
**Per-API overrides**: Some APIs have quirks that override the baseline:
- **MusicBrainz**: HTTP 503 is their rate-limit signal (not a server error)
- **OpenAI**: HTTP 429 with body `code: "insufficient_quota"` is `:auth_error` (permanent), not `:rate_limit`
Override in the ErrorResponse module by pattern-matching on status + body before
falling through to `MusicLibrary.HttpError.kind/1`.
## Rate Limiting
### Intervals (from architecture doc)
| API | Interval |
|-----|----------|
| MusicBrainz | 1000 ms |
| Last.fm | 500 ms |
| Discogs | 2000 ms |
| Wikipedia | 1000 ms |
| BraveSearch | 1000 ms |
| OpenAI | 250 ms |
### Req.RateLimiter
ETS-backed, per-API enforcement via Req request step. Configured per-request:
```elixir
Req.new(plugins: [{Req.RateLimiter, id: :music_brainz}])
```
The `:id` atom corresponds to the `Req.RateLimiter` ETS table entry. In tests,
use `Req.RateLimiter.Clock` behaviour with `SystemClock` implementation.
## Worker Error Handling
Workers that call APIs follow this flow:
```elixir
defmodule MusicLibrary.Worker.FetchArtistInfo do
use Oban.Worker
@impl true
def perform(%{args: %{"musicbrainz_id" => mbid}}) do
case Artists.fetch_info(mbid) do
{:ok, _artist} ->
:ok
{:error, :no_english_wikipedia} ->
# App-layer permanent failures — match first
{:cancel, :no_english_wikipedia}
{:error, reason} ->
# Forward to ErrorHandler for structured HTTP error handling
MusicLibrary.Worker.ErrorHandler.to_oban_result(reason)
end
end
end
```
`ErrorHandler.to_oban_result/1`:
- `%ErrorResponse{retryable?: true}``{:snooze, seconds}` (Oban snooze + retry)
- `%ErrorResponse{retryable?: false}``{:cancel, reason}` (permanent)
- Other errors → `{:error, reason}` (Oban default retry)
### Worker Return Values
| Return | Meaning | Oban Behaviour |
|--------|---------|----------------|
| `:ok` | Success | Job completed |
| `{:error, reason}` | Transient failure | Default retry with backoff |
| `{:snooze, seconds}` | Retryable with delay | Retry after `seconds` |
| `{:cancel, reason}` | Permanent failure | Job cancelled, never retried |
**Never use `{:discard, reason}`** — it's deprecated. Use `{:cancel, reason}`.
## Test Stubs (Req.Test)
All API HTTP calls are stubbed in `config/test.exs`:
```elixir
# config/test.exs
config :req, plug: {
Req.Test,
json_plug: &YourApp.JsonPlug.json/1
}
```
Each API's fixture module provides realistic response data. When adding a new API call:
1. **Add stub in `config/test.exs`** matching the request pattern
2. **Use fixture data** from the appropriate fixtures module
3. **Cover error cases**: rate limit (429), auth error (401/403), not found (404), server error (5xx)
```elixir
# Example: stubbing Last.fm with fixture
Req.Test.stub(LastFm.API, fn conn ->
case conn.method do
"GET" -> Req.Test.json(conn, LastFm.Fixtures.RecentTracks.get())
end
end)
```
### Available API Fixture Modules
| Module | API |
|--------|-----|
| `MusicBrainz.ReleaseFixtures` | MusicBrainz release data |
| `MusicBrainz.ReleaseGroupFixtures` | MusicBrainz release group data |
| `MusicBrainz.ArtistFixtures` | MusicBrainz artist data |
| `Discogs.ArtistFixtures` | Discogs artist profiles |
| `LastFm.ArtistFixtures` | Last.fm artist info |
| `LastFm.Fixtures.RecentTracks` | Last.fm scrobble history |
| `Wikipedia.Fixtures` | Wikipedia extracts |
## Adding a New API Integration
Step-by-step:
1. **Create directory** `lib/music_library/<service>/`
2. **Create Config** (`api/config.ex`) — NimbleOptions schema
3. **Create ErrorResponse** (`api/error_response.ex`) — implement behaviour, handle quirks
4. **Create API** (`api.ex`) — Req client with rate limiter plugin
5. **Create Facade** (`<service>.ex`) — public functions, delegates to API
6. **Register rate limiter** in `config/config.exs` with interval
7. **Add test stubs** in `config/test.exs`
8. **Create fixture module** in `test/support/fixtures/`
9. **Update architecture doc** — add to External API Integrations table
10. **Add worker** (if needed) — thin wrapper with ErrorHandler
-130
View File
@@ -1,130 +0,0 @@
---
name: git-commit
description: Use this skill when committing code, writing commit messages, staging changes, or preparing to push. Use PROACTIVELY when the user mentions "commit", "git commit", "commit message", "push", "stage", "what should I commit", or when code changes are complete and ready to be committed. Also use when the user asks about commit conventions or style.
---
# Git Commit Conventions
Project-specific commit message rules and workflow. These conventions are enforced
and commits that don't follow them will be rejected.
## Checklist Before Committing
1. **Is there a Backlog task?** If the work maps to a task, the task ID MUST prefix
the commit subject.
2. **Is the subject under 60 characters?** Including the task ID prefix.
3. **Is this a single logical change?** One change per commit. If a change spans
multiple tasks, split the commit.
4. **Did you run precommit checks?** `mise run dev:precommit` before committing.
## Commit Message Format
```
<task-id-prefix>: <imperative present tense summary, ≤60 chars>
<optional body with details, wrapped at 72 chars>
<version table for dependency updates>
```
### Subject Line Rules
- **Imperative present tense**: "Add scrobble rule validation" NOT "Added..." or "Adds..."
- **Describe intent/behavior**, not implementation details: "Fix record ordering on search page" NOT "Change ORDER BY clause in list_records"
- **Single-line, under 60 characters**
- **Task ID prefix when applicable**: `ML-3: fix scrobble rule ordering`
- The prefix counts toward the 60-char limit
### Examples
```
# With Backlog task
ML-3: fix scrobble rule ordering
# Without task (trivial fixes)
Fix typo in artist biography template
# Dependency updates
Update dependencies
mix:
bandit 1.6.10 => 1.6.11
phoenix_live_view 1.1.0 => 1.1.1
# NPM dependency updates
Update npm dependencies
npm:
tailwindcss 4.1.4 => 4.1.5
sortablejs 1.15.3 => 1.15.6
# Revert
Revert "ML-3: fix scrobble rule ordering"
```
### Dependency Update Format
The subject is **always** "Update dependencies" (Mix) or "Update npm dependencies" (npm).
Never use "bump" or "upgrade" in the subject.
The body MUST list each specific version change as `package_name from => to`:
```
mix:
bandit 1.6.10 => 1.6.11
phoenix_live_view 1.1.0 => 1.1.1
```
### What NOT to Include
- ❌ "Co-Authored-By" references in the message body
- ❌ Multi-line subjects
- ❌ Past tense ("Added", "Fixed")
- ❌ Implementation details ("Changed the SQL query")
- ❌ "bump" or "upgrade" for dependency updates
- ❌ Multiple unrelated changes in one commit
## Workflow
### Before Committing
```bash
# Run precommit checks
mise run dev:precommit
```
This runs: format check, gettext check, credo, sobelow, mix_audit, shellcheck,
Docker image validation, and asset build.
### Staging
Stage specific files, not `git add .`:
```bash
git add lib/music_library/records.ex test/music_library/records_test.exs
```
### Commit Message Validation
After writing the commit message, verify:
1. Subject is imperative present tense
2. Subject is ≤60 characters (including task ID)
3. Task ID references the correct Backlog task
4. Body describes what changed (if non-trivial)
### Backlog Task Integration
- **One task ID per commit subject.** If work spans tasks, split the commit.
- **Read the task's implementation plan** before committing to verify the plan
was followed.
- **When finalizing a task**, the commit should be the last logical change
for that task.
## Prohibited
- **Never create, comment on, close, or reopen GitHub issues** — only the user does that. Use Backlog.md for task management.
- **Never reference GitHub issue numbers** in commit messages. Use Backlog task IDs.
- **Never use `{:discard, reason}`** — use `{:cancel, reason}` in Oban workers (related but common in commits).
-216
View File
@@ -1,216 +0,0 @@
---
name: oban-worker
description: Use this skill when creating, modifying, debugging, or reviewing ANY Oban worker. Use PROACTIVELY when the user mentions "worker", "Oban", "background job", "job", "cron", "queue", "perform", "enqueue", "schedule", "async", "snooze", "cancel", "discard", or asks about running work in the background. Also use when adding Oban plugins, changing queue configuration, or writing worker tests.
---
# Oban Worker Development
Project-specific patterns for Oban background job workers. Every worker follows
consistent conventions for structure, error handling, queue assignment, and testing.
## Checklist Before Creating or Modifying a Worker
1. **Does the worker delegate to a context module?** `perform/1` must be a thin wrapper.
No business logic inline.
2. **Is the queue correct?** Rate-limited APIs get dedicated queues with concurrency 1.
DB-intensive operations go to `heavy_writes`. General tasks go to `default`.
3. **Does error handling use `ErrorHandler`?** Workers calling APIs route HTTP errors
through `MusicLibrary.Worker.ErrorHandler.to_oban_result/1`.
4. **Is the worker registered under the correct Oban instance?** Production uses
`MusicLibrary.BackgroundRepo` as the Oban instance.
## Worker Structure
```elixir
defmodule MusicLibrary.Worker.FetchArtistInfo do
use Oban.Worker,
queue: :default, # Pick the right queue (see below)
max_attempts: 3, # Oban default, override if needed
unique: [period: 60] # Optional: prevent duplicate jobs
@impl true
def perform(%Oban.Job{args: %{"musicbrainz_id" => mbid}}) do
# Delegate to context — no business logic here
case Artists.fetch_info(mbid) do
{:ok, _artist} ->
:ok
# App-layer permanent failures matched FIRST
{:error, :no_english_wikipedia} ->
{:cancel, :no_english_wikipedia}
{:error, :cover_not_available} ->
{:cancel, :cover_not_available}
# Everything else routed through ErrorHandler
{:error, reason} ->
MusicLibrary.Worker.ErrorHandler.to_oban_result(reason)
end
end
end
```
## Queues
| Queue | Concurrency | Use For |
|-------|-------------|---------|
| `default` | 10 | General async tasks, non-API operations |
| `heavy_writes` | 1 | DB-intensive or serialized operations |
| `music_brainz` | 1 | MusicBrainz API calls |
| `discogs` | 1 | Discogs API calls |
| `wikipedia` | 1 | Wikipedia API calls |
| `last_fm` | 1 | Last.fm API calls |
**Rule**: Any worker that calls an external API goes on that API's dedicated queue.
The concurrency of 1 combined with `Req.RateLimiter` ensures the API rate limit is
never exceeded.
## Return Values
| Return | Meaning | Oban Behaviour |
|--------|---------|----------------|
| `:ok` | Success | Job marked complete |
| `{:ok, result}` | Success with result | Job marked complete |
| `{:error, reason}` | Transient failure | Oban retries with backoff |
| `{:snooze, seconds}` | Retryable with delay | Oban retries after `seconds` |
| `{:cancel, reason}` | Permanent failure | Job cancelled, never retried |
**CRITICAL**: Never use `{:discard, reason}` — it's deprecated since Oban 2.14.
Always use `{:cancel, reason}`.
## Error Handling Flow
```
perform/1
├── App-layer permanent failures (match FIRST)
│ ├── :no_english_wikipedia → {:cancel, :no_english_wikipedia}
│ ├── :cover_not_available → {:cancel, :cover_not_available}
│ └── ...other domain-specific permanent failures
└── Everything else → MusicLibrary.Worker.ErrorHandler.to_oban_result(reason)
├── %ErrorResponse{retryable?: true} → {:snooze, seconds}
├── %ErrorResponse{retryable?: false} → {:cancel, reason}
└── Other errors → {:error, reason}
```
The order matters: **app-layer atoms first**, then fall through to `ErrorHandler`.
This ensures domain-specific permanent failures (e.g., no Wikipedia article exists)
are handled correctly before generic HTTP error classification kicks in.
## Cron Workers
Scheduled via `Oban.Plugins.Cron` in production config:
```elixir
# config/prod.exs
config :music_library, Oban,
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"@daily", MusicLibrary.Worker.SendRecordsOnThisDayEmail},
{"*/5 * * * *", MusicLibrary.Worker.RefreshScrobbles}
]}
]
```
Cron workers follow the same thin-wrapper pattern. The cron expression is the only
difference from on-demand workers.
### All Cron Workers
| Schedule | Worker | Queue |
|----------|--------|-------|
| Every 12h | `ApplyScrobbleRules` | heavy_writes |
| Every 12h | `PruneAssetCache` | default |
| Daily 2 AM | `PruneAssets` | default |
| Daily 3 AM | `RepoVacuum` | heavy_writes |
| Daily 4 AM | `RepoOptimize` | heavy_writes |
| Monthly 1st, 6 AM | `RecordRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 7 AM | `RecordGenerateAllEmbeddings` | heavy_writes |
| Monthly 1st, 8 AM | `ArtistRefreshAllMusicBrainzData` | music_brainz |
| Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs |
| Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia |
| Daily 7 AM | `SendRecordsOnThisDayEmail` | default |
| Every 5 min | `RefreshScrobbles` | last_fm |
## Oban Plugins (Production)
| Plugin | Config | Purpose |
|--------|--------|---------|
| `Pruner` | `max_age: 43200` (12h) | Clean up completed jobs |
| `Reindexer` | `schedule: "@weekly"` | Reindex for query performance |
| `Cron` | timezone: `"Europe/London"` | Schedule recurring jobs |
## Batch Workers (Self-Chaining)
Some workers process data in batches, enqueuing their next batch on completion:
```elixir
def perform(%{args: %{"page" => page}}) do
case process_page(page) do
{:ok, []} ->
:ok # No more data, done
{:ok, _results} ->
# Enqueue next page
%{page: page + 1}
|> __MODULE__.new()
|> Oban.insert()
:ok
end
end
```
The `Records.Batch` and `Artists.Batch` modules provide shared batch infrastructure
(`stream + transaction + error accumulation`). Bulk workers should use these rather
than implementing their own batching logic.
## Testing Workers
### Manual mode
Oban runs in manual testing mode — jobs don't auto-execute. Use `perform_job/2`:
```elixir
import Oban.Testing, only: [perform_job: 2]
test "fetches artist info" do
job = MyWorker.new(%{musicbrainz_id: "abc-123"})
assert :ok = perform_job(MyWorker, job.args)
end
```
### Assert enqueued downstream jobs
Workers that enqueue other jobs MUST verify with `assert_enqueued`:
```elixir
assert_enqueued(worker: FetchArtistImage, args: %{musicbrainz_id: "abc-123"})
```
`perform_job` returning `{:ok, []}` is NOT sufficient — verify the expected downstream
workers were enqueued with correct args.
### Testing all return states
```elixir
# Success
assert :ok = perform_job(MyWorker, %{id: valid_id})
# Transient (Oban retries)
assert {:error, :rate_limit} = perform_job(MyWorker, %{id: rate_limited_id})
# Permanent (job cancelled)
assert {:cancel, :no_english_wikipedia} = perform_job(MyWorker, %{id: missing_id})
```
## Non-Fatal Enrichment
Context functions that enrich records (colors, embeddings) use a private
`best_effort_*` helper pattern — log a warning and return unchanged data rather
than surfacing `{:error, reason}`. Workers calling these functions don't need
error handling for enrichment failures.
-76
View File
@@ -1,76 +0,0 @@
---
name: query-reporter
description: Capture Ecto SQL queries to a log file for analysis. Use this skill when the user asks to trace, capture, log, or inspect database queries, or mentions "SQL log", "query trace", "query capture", "what queries does this page run". Also use proactively when investigating slow pages, debugging N+1 queries, analyzing database performance, or when you need to understand what SQL a LiveView or action produces. Requires a running Phoenix server with Tidewave MCP.
---
# Query Reporter
Captures all Ecto queries executed by the main Repo to a log file as executable
SQL with interpolated parameters and source location comments.
## Before You Start
This skill requires the Tidewave MCP tools. Before proceeding, check whether
`mcp__tidewave__project_eval` is available in your tool list.
If Tidewave is not available, stop and tell the user:
"The query reporter requires a running Phoenix server with Tidewave. Please start
the server with `mise run dev:console` and ensure Tidewave is loaded, then try again."
## Workflow
### 1. Start capturing
Pick a file path (use `/tmp/` to avoid cluttering the project):
```elixir
mcp__tidewave__project_eval(code: ~s[MusicLibrary.QueryReporter.start("/tmp/queries.sql")])
```
This truncates the file if it exists. Calling `start/1` again restarts with a fresh file.
### 2. Trigger the queries you want to capture
Navigate to a page, click a button, run a context function — whatever action you want
to trace. Use Chrome DevTools MCP tools to visit pages if needed.
### 3. Stop capturing
```elixir
mcp__tidewave__project_eval(code: ~s[MusicLibrary.QueryReporter.stop()])
```
### 4. Read the log
Read the file you specified in step 1 to see all captured queries.
## Output Format
Each query in the log file looks like:
```sql
-- MusicLibrary.Collection.list_records/2, at: lib/music_library/collection.ex:42
-- total=2.6ms db=2.6ms queue=0.0ms decode=0.0ms
SELECT r0."id", r0."title" FROM "records" AS r0 WHERE (r0."purchased_at" IS NOT NULL) LIMIT 20;
```
- The first comment shows the Elixir function and file:line that originated the query
- The second comment shows timing: total, db (query execution), queue (connection wait), decode
- Parameters are fully interpolated (no `?` placeholders)
- Each query ends with `;` and is separated by a blank line
- Queries are executable — you can paste them into `sqlite3` or `mcp__tidewave__execute_sql_query`
## What Gets Captured
- All queries through `MusicLibrary.Repo` (the main application database)
- Includes SELECT, INSERT, UPDATE, DELETE, and transaction statements (BEGIN/COMMIT)
- Does **not** capture BackgroundRepo (Oban) or TelemetryRepo queries
## Tips
- The reporter adds minimal overhead (one file append per query), but remember to
stop it when you're done to avoid filling the log with unrelated queries
- LiveView pages fire queries twice on initial load (once for the static mount,
once for the connected mount) — this is normal Phoenix behaviour
- To capture queries from a specific action, start the reporter, perform only that
action, then stop immediately
-194
View File
@@ -1,194 +0,0 @@
---
name: sqlite-optimization
description: Use this skill when writing, reviewing, or optimizing ANY Ecto query, SQL fragment, migration, or database index. Use PROACTIVELY when the user mentions "query", "SQL", "database", "index", "migration", "FTS", "full-text search", "json_extract", "json_each", "GROUP BY", "subquery", "performance", "slow query", "N+1", "Ecto query", "Repo.all", "Repo.one", or asks about database design. Also use when adding WHERE clauses, JOINs, or changing how data is fetched. The SQLite-specific patterns in this project are easy to miss and cause silent performance regressions.
---
# SQLite Optimization
Project-specific SQLite query patterns, index strategies, and migration conventions.
These rules are load-bearing — dev datasets are small enough to hide problems that
degrade production performance.
## Checklist Before Writing Any Database Query
1. **Check for existing expression indexes** before writing a query. Your `WHERE` or
`GROUP BY` clause must match the index expression textually.
2. **If no index exists for your query**, add one in a migration with an `up`/`down`
and a comment explaining which query it helps.
3. **FTS5 tables are trigger-synced** — never INSERT/UPDATE/DELETE directly on
`records_search_index`. Write to `records` instead.
4. **Config-driven constants.** Pagination defaults and magic numbers live in
`config/config.exs`, read via `Application.compile_env!/2`.
## Expression Indexes & SQL Text Matching
SQLite treats `json_extract(column, '$.path')` and `column ->> '$.path'` as
**semantically equal but textually distinct**. If an expression index uses
`json_extract`, your query MUST use the same text:
```elixir
# Index definition (in migration):
# CREATE INDEX idx_records_format ON records(json_extract(data, '$.format'));
# CORRECT — matches index text
from(r in Record, where: fragment("json_extract(?, '$.format')", r.data) == "vinyl")
# WRONG — won't use the index
from(r in Record, where: fragment("? ->> '$.format'", r.data) == "vinyl")
```
Same applies to `GROUP BY` — match the expression index exactly:
```elixir
# Index exists on: json_extract(r.data, '$.artist_name')
# CORRECT GROUP BY:
from(r in Record,
group_by: fragment("json_extract(?, '$.artist_name')", r.data)
)
```
## Subquery Materialization with `limit: -1`
When a date-filtered subquery feeds an outer `GROUP BY`, SQLite may flatten the
subquery and prefer the wrong composite index. **Force materialization with
`limit: -1`**:
```elixir
filtered = from(t in Track,
where: t.scrobbled_at >= ^start_date,
limit: -1 # forces materialization, preserves range-scan index
)
from(t in subquery(filtered),
group_by: t.artist_name,
select: %{artist: t.artist_name, count: count()}
)
|> Repo.all()
```
## Correlated Scalar Subqueries
For **small-LIMIT result enrichment** from large lookup tables, use correlated scalar
subqueries instead of `LEFT JOIN`. Cost scales with the outer LIMIT, not the lookup
table size:
```elixir
# CORRECT — cost proportional to LIMIT
from(r in Record,
limit: 20,
select: %{
id: r.id,
title: r.title,
scrobble_count: fragment(
"(SELECT count(*) FROM scrobbled_tracks WHERE record_id = ?)", r.id
)
}
)
# WRONG — joins entire scrobbled_tracks table
from(r in Record,
left_join: s in subquery(counts_query), on: s.record_id == r.id,
limit: 20
)
```
## FTS5 Full-Text Search
The `records_search_index` table is an FTS5 **virtual table auto-synced via database
triggers**. Treat it as read-only:
```elixir
# CORRECT — insert into the source table, triggers sync FTS
Repo.insert!(%Record{title: "Dark Side of the Moon", ...})
# CORRECT — read from FTS index
from(ri in "records_search_index",
where: fragment("records_search_index MATCH ?", ^query)
)
# WRONG — never write directly to FTS
Repo.insert_all("records_search_index", [...])
```
## JSON Column Patterns
### json_each() for expanding arrays
```elixir
from(r in Record,
cross_join: fragment("json_each(?)", r.genres),
where: fragment("value = ?", ^genre),
select: r
)
```
### json_extract() for field access
```elixir
from(r in Record,
where: fragment("json_extract(?, '$.format') = ?", r.data, ^format)
)
```
## Materialized Views via Triggers
SQLite lacks native materialized views. Use explicit `up`/`down` in migrations for
non-reversible DDL. Read-only schemas for materialized/view tables:
```elixir
# Read-only schema (no PK, no changeset, no timestamps)
defmodule Records.RecordRelease do
use Ecto.Schema
@primary_key false
schema "record_releases" do
field :record_id, :binary_id
field :release_id, :string
field :cover_hash, :string
field :purchased_at, :utc_datetime
end
end
```
## Migration Conventions
Every migration must:
- Provide **both `up` and `down`** for any `execute` block
- Comment every index explaining which query it helps
- Use explicit triggers for materialized views
```elixir
def change do
execute(
# UP
"CREATE INDEX idx_records_purchase_date ON records(purchased_at);",
# DOWN — always provided
"DROP INDEX IF EXISTS idx_records_purchase_date;"
)
end
```
## Query Patterns to Avoid
| Anti-pattern | Why | Fix |
|-------------|-----|-----|
| `Repo.all()` then `Enum.filter()` | Loads entire table into memory | Filter in SQL with `where:` |
| `Enum.map()` with inline `Repo` calls | N+1 queries | Use preload or joins |
| `preload: [:association]` on large result sets | Cartesian product in memory | Use `join:` and `select:` |
| Dynamic `order_by` without corresponding index | Full table sort | Add composite index matching sort columns |
| `distinct: true` without index | Temporary B-tree | Add index on distinct columns |
## Using QueryReporter to Verify
Before committing a query change, use the query-reporter skill to capture the actual
SQL and verify:
1. The query uses the expected indexes (check `EXPLAIN QUERY PLAN`)
2. No unexpected full table scans
3. Subqueries are materialized when needed
```bash
# In SQLite console (mise run dev:sqlite-console):
EXPLAIN QUERY PLAN SELECT ...;
```
-229
View File
@@ -1,229 +0,0 @@
---
name: testing
description: Use this skill when writing, running, debugging, or fixing ANY tests. Also use when the user mentions "test", "testing", "spec", "ExUnit", "assert", "fixture", "test helper", "test setup", "run the tests", "test failure", "failing test", "add a test", "write a test", "test coverage", "LiveViewTest", "PhoenixTest", "sandbox", or asks about test conventions. Use PROACTIVELY whenever creating or modifying test files, test/support modules, or when a code change requires test updates.
---
# Testing
Project-specific testing conventions, patterns, and pitfalls for the Music Library
codebase. These rules override generic Elixir/Phoenix testing defaults.
## Checklist Before Writing Any Test
1. **Feature setup stays in the test module that needs it**, not in shared case templates
(`ConnCase`, `DataCase`). Only add to shared templates when EVERY test using that
template genuinely needs it.
2. **Use fixture modules for test data.** Fixtures call through context functions (not
raw `Repo.insert`). Use `System.unique_integer([:positive])` for unique names.
3. **Assert specific values, not just shapes.** Prefer `assert data == expected` over
`assert data != nil` or `assert {:ok, _} = result`. Wildcard matches (`_`) signal the
test is too vague.
4. **Error assertions must match the specific error type.** `assert {:error, _reason}` is
too broad. Match the struct or atom: `%Req.TransportError{reason: :timeout}`, `:no_session_key`.
5. **Verify outcomes through context modules**, not just UI assertions. Delete tests
should assert both `refute has_element?` AND `assert_raise Ecto.NoResultsError`.
6. **No boilerplate-only tests.** Don't add test files that just verify Phoenix generator
output. Tests must exercise application behaviour.
## Running Tests
```bash
# All tests (partitioned, like CI)
mise run test
# Specific file
mix test test/music_library/records_test.exs
# Specific test by line number
mix test test/music_library/records_test.exs:123
# With tags
mix test --only tag_name
mix test --only logged_out
# Limit failures for faster feedback
mix test --max-failures 5
```
## Test Styles — When to Use Each
### PhoenixTest (`assert_has`, `click_button`, `click_link`, `visit`)
Use for **page-level LiveView tests** where you're testing navigation and DOM presence:
```elixir
{:ok, view, _html} = live(conn, ~p"/collection")
assert_has(view, ".record-card")
```
### Phoenix.LiveViewTest (`form/3`, `render_submit/1`, `element/2`, `render_click/1`)
Use for **LiveComponent interactions** where `phx-target={@myself}` is involved:
```elixir
html = render_submit(form(view, "#record-form", record: attrs))
```
### Context Tests (standard ExUnit + DataCase)
Use for **business logic** that doesn't involve the web layer:
```elixir
assert {:ok, %Record{}} = Records.create_record(valid_attrs)
```
## SQLite Test Gotchas
### Timestamp precision
SQLite `utc_datetime` has **second-level precision**. Rapid inserts get identical
timestamps, breaking ordering assertions:
```elixir
# DON'T rely on auto-timestamps for ordering
record1 = record_fixture()
record2 = record_fixture()
# DO manually set timestamps when order matters
{:ok, record1} = Repo.update_all(Record, set: [inserted_at: ~N[2024-01-01 00:00:00]])
{:ok, record2} = Repo.update_all(Record, set: [inserted_at: ~N[2024-01-02 00:00:00]])
```
### VACUUM / low-level ops
Database operations like `VACUUM` cannot run in the test sandbox. **Do not write a test**
that asserts the sandbox error message — delete or skip it.
## Swoosh Email Testing
Use `Swoosh.Adapters.Sandbox`, NOT `Swoosh.Adapters.Test`:
```elixir
setup do
SwooshSandbox.checkout()
on_exit(fn -> SwooshSandbox.checkin() end)
end
```
When the mailer is invoked from a **separate process** (e.g., a GenServer started in
the test), share the sandbox:
```elixir
SwooshSandbox.allow(self(), gen_server_pid)
```
## API Stubs (Req.Test)
All external HTTP calls are stubbed in tests via `config/test.exs` using `Req.Test`.
Each API has fixture modules that provide realistic response data.
When adding a new API call, you must:
1. Add a stub in `config/test.exs`
2. Use fixture data from the appropriate fixtures module
3. Ensure the stub covers error cases too (rate limiting, auth errors, 404s)
## Worker Tests
Workers that enqueue other jobs MUST use `assert_enqueued`:
```elixir
# DON'T just check perform_job return value
assert {:ok, []} = perform_job(worker, args)
# DO verify downstream enqueues
assert_enqueued(worker: SomeDownstreamWorker, args: %{id: id})
```
Worker return value testing:
```elixir
# Success
assert :ok = perform_job(MyWorker, %{id: id})
# Transient error (Oban will retry)
assert {:error, :rate_limit} = perform_job(MyWorker, %{id: id})
# Permanent termination
assert {:cancel, :no_english_wikipedia} = perform_job(MyWorker, %{id: id})
```
## Don't Duplicate Assertions
### Don't test the same guard at every call site
If a shared check (e.g., session key presence) is enforced in one place, test it once.
Don't duplicate the same assertion across every function that calls the shared check.
### Consolidate identical assertions across endpoints
When multiple routes share the same plug/middleware behaviour (e.g., auth), test it
once with a loop or parameterised approach, not N separate identical tests.
## Test Organization
### Tags
```elixir
@tag :logged_out # Public endpoint tests (no auth)
@tag :capture_log # Tests with expected error log output
# Run only tagged tests
mix test --only logged_out
```
### Test modules structure
```
test/
├── music_library/ # Context tests
├── music_library_web/ # LiveView/controller tests
└── support/
├── conn_case.ex # HTTP test setup
├── data_case.ex # Database sandbox
├── live_test_helpers.ex
└── fixtures/ # Fixture modules
```
### Available fixture modules
| Module | Creates |
|--------|---------|
| `RecordsFixtures` | Records with MusicBrainz data |
| `RecordSetsFixtures` | Record sets with items |
| `OnlineStoreTemplatesFixtures` | Store templates |
| `ArtistInfoFixtures` | ArtistInfo records |
| `ScrobbleRulesFixtures` | Scrobble rules |
| `ScrobbledTracksFixtures` | Last.fm tracks |
| `Discogs.ArtistFixtures` | Discogs API responses |
| `LastFm.ArtistFixtures` | Last.fm API responses |
| `MusicBrainz.*Fixtures` | MusicBrainz API responses |
| `Wikipedia.Fixtures` | Wikipedia API responses |
Always call through the fixture module — never `Repo.insert` directly.
## Testing JS Hooks
Use `render_hook/3` for testing JS hook interactions:
```elixir
assert render_hook(view, "music_library:clipcopy", %{text: "copy me"}) =~ "copy me"
```
## Testing `handle_async`
Always handle all three cases:
```elixir
assert {:noreply, socket} = handle_async(socket, :task_ref, {:ok, {:ok, result}})
assert {:noreply, socket} = handle_async(socket, :task_ref, {:ok, {:error, reason}})
assert {:noreply, socket} = handle_async(socket, :task_ref, {:exit, reason})
```
## Oban in Tests
Oban runs in **manual testing mode** — jobs don't auto-execute. Use `perform_job/2`
from `Oban.Testing` to execute them explicitly.
-167
View File
@@ -1,167 +0,0 @@
---
name: ui-framework
description: "Use this skill when working with LiveViews, UI components using the Phoenix framework, and in general ANY FILE THAT CONTAINS HTML. Use proactively when editing .heex files, LiveView modules, LiveComponents, or any component module under lib/music_library_web/components/."
metadata:
managed-by: usage-rules
---
# Project UI Conventions
Before writing or modifying UI code, follow these project-specific rules. They take
precedence over the generic reference material below.
## Checklist
1. **Gettext all user-facing strings.** Every string visible to users must be wrapped
in `gettext/1` or `pgettext/2`. After adding new strings, run
`mix gettext.extract --merge` to update `.pot`/`.po` files.
2. **Dark mode always paired.** Every color class needs its dark variant:
`text-zinc-900 dark:text-zinc-100`, `bg-zinc-50 dark:bg-zinc-800`, etc.
3. **Wishlisted items get dimmed styling:** `opacity-60 hover:opacity-100 transition-opacity`.
4. **Icons inside buttons use the `icon` class** — not explicit size classes like
`h-5 w-5` or `size-3.5`. Fluxon auto-sizes icons based on the button's `size` prop.
5. **Artist names use `joinphrase`** from MusicBrainz — never join artist names with
a literal `", "`.
6. **Streams for all collections.** Use LiveView streams, not list assigns. See the
LiveView reference below for stream patterns.
## Component organization
- Generic UI → `CoreComponents`
- Record-specific → `RecordComponents`
- Scrobble-specific → `ScrobbleComponents`
- Search-specific → `SearchComponents`
- Stats-specific → `StatsComponents`, `ChartComponents`
- Extract a function component when identical markup appears in 3+ places.
## LiveView structure
- `mount/3` sets `@current_section`
- `handle_params/3` loads data and sets `@page_title` (via pattern-matched private `page_title/2`)
- `handle_info/2` receives LiveComponent messages
- LiveComponents communicate with parent via `send(self(), {__MODULE__, msg})`
## Toast notifications
- In LiveViews: `put_toast(socket, :info, "message")` (arity 3)
- In LiveComponents: `put_toast!(:info, "message")` (arity 2)
- User-facing error reasons go through `ErrorMessages.friendly_message/1` — never `inspect(reason)`
## Routes
- Three routes per resource with show modals: `:show`, `:edit` (at `/show/edit`), `:add_*`
- Modals close via `JS.patch` back to the base route
- Search state in URL query params via `push_patch`
- Conditional links: `purchased_at` determines `/collection/` vs `/wishlist/` paths
## Fluxon components
Read `references/fluxon.md` when you need to use a specific Fluxon component —
it contains the full attribute/slot API for each component. You can also search with:
```sh
mix usage_rules.search_docs "component_name" -p fluxon
```
## Visual verification with Chrome DevTools
Use the Chrome DevTools MCP tools to verify UI changes in the running dev server.
### When to screenshot
Take screenshots after **visual** changes — styling, layout, component additions or
removals, responsive adjustments. Skip for logic-only changes (event handlers, assigns
that don't affect rendering).
### Authentication
The dev server requires login. If you get redirected to `/login`, fill the password
field and submit before navigating to your target page:
1. `take_snapshot` to get element UIDs
2. `fill` the password field with the dev password from `config/config.exs`
(`login_password` under `:music_library, MusicLibraryWeb`)
3. `click` the Login button
4. `navigate_page` to your target route
### Dark mode verification
When your change adds or modifies **color classes, backgrounds, borders, or custom
styling**, take two screenshots:
1. Default (light mode) — screenshot as-is
2. Dark mode — run `evaluate_script` with
`document.documentElement.classList.add('dark')`, then screenshot
This connects directly to the "Dark mode always paired" checklist rule. Both screenshots
must look correct.
When the change is limited to Fluxon component attributes (e.g., `variant`, `size`) with
no custom color classes, dark mode is handled by the component library — a single
screenshot suffices.
### Before/after comparison
For styling changes, take a screenshot **before** editing so you can describe what
changed. This helps confirm the change had the intended effect and didn't break
surrounding elements.
### Worktree limitation
The dev server at the configured port serves the **main repo**, not a git worktree. If
you are working in a worktree, visual verification reflects the main branch state. To
verify worktree changes visually, start a separate server in the worktree with
`mise run dev:worktree-setup` or apply the change to the main repo first.
---
<!-- usage-rules-skill-start -->
## Additional References
- [ecto](references/ecto.md)
- [elixir](references/elixir.md)
- [html](references/html.md)
- [liveview](references/liveview.md)
- [phoenix](references/phoenix.md)
- [fluxon](references/fluxon.md)
## Searching Documentation
```sh
mix usage_rules.search_docs "search term" -p phoenix -p phoenix_ecto -p phoenix_html -p phoenix_live_dashboard -p phoenix_live_reload -p phoenix_live_view -p fluxon
```
## Available Mix Tasks
- `mix compile.phoenix`
- `mix phx` - Prints Phoenix help information
- `mix phx.digest` - Digests and compresses static files
- `mix phx.digest.clean` - Removes old versions of static assets.
- `mix phx.gen` - Lists all available Phoenix generators
- `mix phx.gen.auth` - Generates authentication logic for a resource
- `mix phx.gen.auth.hashing_library`
- `mix phx.gen.auth.injector`
- `mix phx.gen.auth.migration`
- `mix phx.gen.cert` - Generates a self-signed certificate for HTTPS testing
- `mix phx.gen.channel` - Generates a Phoenix channel
- `mix phx.gen.context` - Generates a context with functions around an Ecto schema
- `mix phx.gen.embedded` - Generates an embedded Ecto schema file
- `mix phx.gen.html` - Generates context and controller for an HTML resource
- `mix phx.gen.json` - Generates context and controller for a JSON resource
- `mix phx.gen.live` - Generates LiveView, templates, and context for a resource
- `mix phx.gen.notifier` - Generates a notifier that delivers emails by default
- `mix phx.gen.presence` - Generates a Presence tracker
- `mix phx.gen.release` - Generates release files and optional Dockerfile for release-based deployments
- `mix phx.gen.schema` - Generates an Ecto schema and migration file
- `mix phx.gen.secret` - Generates a secret
- `mix phx.gen.socket` - Generates a Phoenix socket handler
- `mix phx.routes` - Prints all routes
- `mix phx.server` - Starts applications and their servers
- `mix compile.phoenix_live_view`
- `mix phoenix_live_view.upgrade`
<!-- usage-rules-skill-end -->
@@ -1,9 +0,0 @@
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
@@ -1,54 +0,0 @@
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
@@ -1,231 +0,0 @@
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
@@ -1,15 +0,0 @@
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
@@ -1,60 +0,0 @@
---
name: update-dependencies
description: Use when the user asks to update, upgrade, or refresh project dependencies, or when outdated dependencies need attention. Also use when the user mentions "outdated", "bump", "upgrade", or asks about dependency versions — even if they don't say "update dependencies" explicitly.
---
# Update Dependencies
This project has five dependency categories checked by `mise run dev:outdated`:
NPM packages, Tailwind, ESBuild, Sqlean (SQLite extensions), and Mix (Hex) packages.
Dependabot handles GitHub Actions, Docker, Mix, and NPM updates via PRs, but the
user may still want to update manually — especially for tooling (Tailwind, ESBuild,
Sqlean) which Dependabot doesn't cover.
## Workflow
### 1. Check what's outdated
Run `mise run dev:outdated`. Read the summary table at the end — it has three columns:
Dependency, Status, and Next Step.
If mix dependencies are outdated, the output usually contains a hex.pm diff preview
link (looks like `https://hex.pm/l/FR8QK`). Offer the user the option to open it in
the browser for manual inspection before proceeding.
### 2. Assess the updates
Before running any update commands, check for major version bumps in the outdated
output. For major bumps (e.g., 1.x → 2.x), look up the changelog or migration guide
before updating — breaking changes may require code modifications.
If nothing is outdated, stop here and tell the user.
### 3. Apply updates
For each outdated category, run the command from the Next Step column. You have two
options depending on what the user prefers:
- **Bulk update**: run `mise run dev:update` to update everything at once.
- **Selective update**: run individual commands from the summary table one at a time.
When in doubt, ask the user which approach they prefer.
### 4. Verify
Run these checks after updating:
1. `mise run dev:outdated` — confirm everything is now up to date
2. `mix compile` — catch compilation errors early
3. `mix assets.build` — verify JS/CSS builds succeed (this depends on compiled
Elixir code, so it must run after `mix compile`). This matters because NPM,
Tailwind, and ESBuild updates can break the asset pipeline.
4. `mise run dev:lint` — catch formatting or credo issues introduced by updates
5. `mix test` — verify nothing is broken
### 5. Commit
Follow the project's commit conventions.
If a major version bump required code changes, those changes belong in the same commit
as the dependency bump — not in a separate commit.
@@ -1,131 +0,0 @@
---
name: update-documentation
description: Use when the user asks to update project documentation, or after significant code changes that may have made docs/architecture.md, docs/project-conventions.md, docs/production-infrastructure.md, or any .claude/skills/*/SKILL.md file stale. Also trigger when new modules, schemas, workers, LiveViews, routes, external integrations have been added or removed, when queues or rate limits change, when testing conventions evolve, or when new commit rules are established.
---
# Update Documentation
Updates `docs/architecture.md`, `docs/project-conventions.md`, `docs/production-infrastructure.md`,
and all `.claude/skills/*/SKILL.md` files to reflect recent codebase changes.
## Guards
**CRITICAL: NEVER modify content within `usage_rules` generated blocks.** These blocks
are delimited by `<!-- usage-rules-skill-start -->` and `<!-- usage-rules-skill-end -->`.
The only skill currently containing such blocks is `ui-framework/SKILL.md`. When
editing any skill file, check for these markers first and treat the content between
them as read-only. If a proposed change would land inside a usage_rules block, skip
that change or restructure it to go outside the block.
## Workflow (Docs)
Steps 13 are independent per file — run them in parallel using subagents (one per
doc file) to speed up analysis. Merge the results before presenting to the user in
step 4.
### 1. Find the last documentation update
```bash
git log -1 --format="%H %as %s" -- docs/architecture.md
git log -1 --format="%H %as %s" -- docs/project-conventions.md
git log -1 --format="%H %as %s" -- docs/production-infrastructure.md
```
### 2. List all commits since then
```bash
git log --oneline <last-commit-hash>..HEAD
```
If there are no commits after the last update, the file is already up to date — skip it.
### 3. Analyze each commit for documentation impact
For each commit, check if it introduced changes relevant to the documentation file:
- **architecture.md**: new/removed/renamed modules, schemas, contexts, workers, routes, external integrations, supervision tree changes, database changes, PubSub topics, LiveView/LiveComponent additions
- **project-conventions.md**: new patterns established across 3+ commits, new conventions visible in code review, changed testing patterns, new error handling approaches, new UI/template conventions
- **production-infrastructure.md**: hosting/deployment changes, database configuration, backup strategy, environment variables, monitoring/observability, CI/CD pipeline, external service integrations, Docker/release configuration
Use `git show --stat <hash>` and `git show <hash>` to understand each commit.
**Skip these — they do not need documentation updates:**
- Bug fixes and minor tweaks that don't change structure
- Dependency version bumps (unless they change a major integration)
- Refactors that rename internals without changing the public API or module structure
- Test-only changes
- Skill or CLAUDE.md changes
### 4. Prepare the update
- Read the current documentation file
- **Follow the existing format and section structure.** Each doc file has an established
layout with specific tables, headings, and conventions. Add new entries to existing
sections rather than inventing new structures. Match the style of surrounding content
(e.g., if a table uses `| Module | Purpose |` columns, add rows in the same format).
- Draft the specific edits needed (additions, modifications, removals)
- Present the proposed changes to the user for review before applying them
### 5. Get human approval
Show the user:
- Which commits drove the changes
- What sections are being added, modified, or removed
- The exact content of each edit
Only apply changes after explicit approval.
### 6. Apply and verify (Docs)
- Apply the approved edits
- Commit with a message describing what was updated (e.g., "Update architecture docs" or "Update project conventions")
## Workflow (Skills)
Skills in `.claude/skills/*/SKILL.md` contain hardcoded reference tables and conventions
that must stay in sync with the codebase. When code changes render a skill stale, update it.
### Which Skills to Check After Code Changes
| Code Change | Skills to Check |
|-------------|-----------------|
| New/removed Oban worker | `oban-worker/SKILL.md` — worker tables (On-Demand, Cron), queue assignments |
| Queue configuration change | `oban-worker/SKILL.md` — Queues table |
| New/removed Oban plugin | `oban-worker/SKILL.md` — Plugins table |
| New/removed API integration | `external-api-integration/SKILL.md` — Rate limit intervals, fixture modules list |
| Rate limit interval change | `external-api-integration/SKILL.md` — Intervals table, `architecture.md` |
| New/removed API fixture module | `external-api-integration/SKILL.md` — Available API Fixture Modules table |
| New test fixture module | `testing/SKILL.md` — Available fixture modules table |
| New SQL pattern becomes convention | `sqlite-optimization/SKILL.md` — add to patterns/anti-patterns |
| New LiveView or LiveComponent | `architecture.md` — LiveViews / LiveComponents tables |
| New/removed schema or context | `architecture.md` — Schemas / Contexts tables |
| New/renamed module (any) | `architecture.md` — relevant section |
| Route changes | `architecture.md` — Router Structure |
| PubSub topic changes | `architecture.md` — PubSub Topics table |
| New/removed JS hook or event | `architecture.md` — JS Hooks / JS Event Listeners tables |
| Testing convention change | `testing/SKILL.md` — relevant section; `project-conventions.md` |
| Commit convention change | `git-commit/SKILL.md` — relevant section; `project-conventions.md` |
| UI convention change | `ui-framework/SKILL.md` — relevant section (outside usage_rules blocks) |
| Production infra change | `production-infrastructure.md`; `error-investigation/SKILL.md` if monitoring changes |
| Dependency category added/removed | `update-dependencies/SKILL.md` — workflow steps |
| Skill added or removed | This file — update the table above |
### Skill Update Workflow
1. **Identify stale content.** After code changes, check the table above for affected skills.
Read the skill file and compare its reference tables, patterns, and conventions against
the current codebase.
2. **Check for usage_rules blocks.** Before editing any skill, read it and identify
`<!-- usage-rules-skill-start -->` / `<!-- usage-rules-skill-end -->` markers.
Content between them is auto-generated and MUST NOT be modified.
3. **Prepare changes.** Apply the same principles as doc updates:
- Follow the existing section structure and formatting
- Add entries to existing tables; don't invent new structures
- Match the style of surrounding content
4. **Get human approval.** Present proposed skill changes alongside doc changes.
5. **Apply and commit.** Use a commit message describing what was updated
(e.g., "Update oban-worker skill for new cron workers").