Move skills to .agents/skills
This commit is contained in:
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 ...;
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 1–3 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").
|
||||
Reference in New Issue
Block a user