setup usage_rules
This commit is contained in:
401
.claude/skills/ash-framework/references/actions.md
Normal file
401
.claude/skills/ash-framework/references/actions.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Actions
|
||||
|
||||
- Create specific, well-named actions rather than generic ones
|
||||
- Put all business logic inside action definitions
|
||||
- Use hooks like `Ash.Changeset.after_action/2`, `Ash.Changeset.before_action/2` to add additional logic
|
||||
inside the same transaction.
|
||||
- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic
|
||||
outside the transaction.
|
||||
- Use action arguments for inputs that need validation
|
||||
- Use preparations to modify queries before execution
|
||||
- Preparations support `where` clauses for conditional execution
|
||||
- Use `only_when_valid?` to skip preparations when the query is invalid
|
||||
- Use changes to modify changesets before execution
|
||||
- Use validations to validate changesets before execution
|
||||
- Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the `Ash` module
|
||||
- A resource could be *only generic actions*. This can be useful when you are using a resource only to model behavior.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Functions to call actions, like `Ash.create` and code interfaces like `MyApp.Accounts.register_user` all return ok/error tuples. All have `!` variations, like `Ash.create!` and `MyApp.Accounts.register_user!`. Use the `!` variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising `!` variation over something like `{:ok, user} = MyApp.Accounts.register_user(...)`.
|
||||
|
||||
All Ash code returns errors in the form of `{:error, error_class}`. Ash categorizes errors into four main classes:
|
||||
|
||||
1. **Forbidden** (`Ash.Error.Forbidden`) - Occurs when a user attempts an action they don't have permission to perform
|
||||
2. **Invalid** (`Ash.Error.Invalid`) - Occurs when input data doesn't meet validation requirements
|
||||
3. **Framework** (`Ash.Error.Framework`) - Occurs when there's an issue with how Ash is being used
|
||||
4. **Unknown** (`Ash.Error.Unknown`) - Occurs for unexpected errors that don't fit the other categories
|
||||
|
||||
These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the `errors` field on the exception.
|
||||
|
||||
## Using Validations
|
||||
|
||||
Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors.
|
||||
|
||||
Validations work on both changesets and queries. Built-in validations that support queries include:
|
||||
- `action_is`, `argument_does_not_equal`, `argument_equals`, `argument_in`
|
||||
- `compare`, `confirm`, `match`, `negate`, `one_of`, `present`, `string_length`
|
||||
- Custom validations that implement the `supports/1` callback
|
||||
|
||||
Common validation patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in validations with custom messages
|
||||
validate compare(:age, greater_than_or_equal_to: 18) do
|
||||
message "You must be at least 18 years old"
|
||||
end
|
||||
validate match(:email, "@")
|
||||
validate one_of(:status, [:active, :inactive, :pending])
|
||||
|
||||
# Conditional validations with where clauses
|
||||
validate present(:phone_number) do
|
||||
where present(:contact_method) and eq(:contact_method, "phone")
|
||||
end
|
||||
|
||||
# only_when_valid? - skip validation if prior validations failed
|
||||
validate expensive_validation() do
|
||||
only_when_valid? true
|
||||
end
|
||||
|
||||
# Action-specific vs global validations
|
||||
actions do
|
||||
create :sign_up do
|
||||
validate present([:email, :password]) # Only for this action
|
||||
end
|
||||
|
||||
read :search do
|
||||
argument :email, :string
|
||||
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) # Validates query arguments
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present([:title, :body]), on: [:create, :update] # Multiple actions
|
||||
end
|
||||
```
|
||||
|
||||
- Create **custom validation modules** for complex validation logic:
|
||||
```elixir
|
||||
defmodule MyApp.Validations.UniqueUsername do
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Validation logic here
|
||||
# Return :ok or {:error, message}
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
validate {MyApp.Validations.UniqueUsername, []}
|
||||
```
|
||||
|
||||
- Make validations **atomic** when possible to ensure they work correctly with direct database operations by implementing the `atomic/3` callback in custom validation modules.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Validations.IsEven do
|
||||
# transform and validate opts
|
||||
|
||||
use Ash.Resource.Validation
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
if is_atom(opts[:attribute]) do
|
||||
{:ok, opts}
|
||||
else
|
||||
{:error, "attribute must be an atom!"}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
# This is optional, but useful to have in addition to validation
|
||||
# so you get early feedback for validations that can otherwise
|
||||
# only run in the datalayer
|
||||
def validate(changeset, opts, _context) do
|
||||
value = Ash.Changeset.get_attribute(changeset, opts[:attribute])
|
||||
|
||||
if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do
|
||||
:ok
|
||||
else
|
||||
{:error, field: opts[:attribute], message: "must be an even number"}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic(changeset, opts, context) do
|
||||
{:atomic,
|
||||
# the list of attributes that are involved in the validation
|
||||
[opts[:attribute]],
|
||||
# the condition that should cause the error
|
||||
# here we refer to the new value or the current value
|
||||
expr(rem(^atomic_ref(opts[:attribute]), 2) != 0),
|
||||
# the error expression
|
||||
expr(
|
||||
error(^InvalidAttribute, %{
|
||||
field: ^opts[:attribute],
|
||||
# the value that caused the error
|
||||
value: ^atomic_ref(opts[:attribute]),
|
||||
# the message to display
|
||||
message: ^(context.message || "%{field} must be an even number"),
|
||||
vars: %{field: ^opts[:attribute]}
|
||||
})
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
- **Avoid redundant validations** - Don't add validations that duplicate attribute constraints:
|
||||
```elixir
|
||||
# WRONG - redundant validation
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
constraints min_length: 1
|
||||
end
|
||||
|
||||
validate present(:name) do # Redundant! allow_nil? false already handles this
|
||||
message "Name is required"
|
||||
end
|
||||
|
||||
validate attribute_does_not_equal(:name, "") do # Redundant! min_length: 1 already handles this
|
||||
message "Name cannot be empty"
|
||||
end
|
||||
|
||||
# CORRECT - let attribute constraints handle basic validation
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
constraints min_length: 1
|
||||
end
|
||||
```
|
||||
|
||||
## Using Preparations
|
||||
|
||||
Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context.
|
||||
|
||||
Common preparation patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in preparations
|
||||
prepare build(sort: [created_at: :desc])
|
||||
prepare build(filter: [active: true])
|
||||
|
||||
# Conditional preparations with where clauses
|
||||
prepare build(filter: [visible: true]) do
|
||||
where argument_equals(:include_hidden, false)
|
||||
end
|
||||
|
||||
# only_when_valid? - skip preparation if prior validations failed
|
||||
prepare expensive_preparation() do
|
||||
only_when_valid? true
|
||||
end
|
||||
|
||||
# Action-specific vs global preparations
|
||||
actions do
|
||||
read :recent do
|
||||
prepare build(sort: [created_at: :desc], limit: 10)
|
||||
end
|
||||
end
|
||||
|
||||
preparations do
|
||||
prepare build(filter: [deleted: false]), on: [:read, :update]
|
||||
end
|
||||
```
|
||||
|
||||
## Using Changes
|
||||
|
||||
Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations.
|
||||
|
||||
Common change patterns:
|
||||
|
||||
```elixir
|
||||
# Built-in changes with conditions
|
||||
change set_attribute(:status, "pending")
|
||||
change relate_actor(:creator) do
|
||||
where present(:actor)
|
||||
end
|
||||
change atomic_update(:counter, expr(^counter + 1))
|
||||
|
||||
# Action-specific vs global changes
|
||||
actions do
|
||||
create :sign_up do
|
||||
change set_attribute(:joined_at, expr(now())) # Only for this action
|
||||
end
|
||||
end
|
||||
|
||||
changes do
|
||||
change set_attribute(:updated_at, expr(now())), on: :update # Multiple actions
|
||||
change manage_relationship(:items, type: :append), on: [:create, :update]
|
||||
end
|
||||
```
|
||||
|
||||
- Create **custom change modules** for reusable transformation logic:
|
||||
```elixir
|
||||
defmodule MyApp.Changes.SlugifyTitle do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, _context) do
|
||||
title = Ash.Changeset.get_attribute(changeset, :title)
|
||||
|
||||
if title do
|
||||
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
|
||||
Ash.Changeset.change_attribute(changeset, :slug, slug)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
change {MyApp.Changes.SlugifyTitle, []}
|
||||
```
|
||||
|
||||
- Create a **change module with lifecycle hooks** to handle complex multi-step operations:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Changes.ProcessOrder do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, context) do
|
||||
changeset
|
||||
|> Ash.Changeset.before_transaction(fn changeset ->
|
||||
# Runs before the transaction starts
|
||||
# Use for external API calls, logging, etc.
|
||||
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
|
||||
changeset
|
||||
end)
|
||||
|> Ash.Changeset.before_action(fn changeset ->
|
||||
# Runs inside the transaction before the main action
|
||||
# Use for related database changes in the same transaction
|
||||
Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now())
|
||||
end)
|
||||
|> Ash.Changeset.after_action(fn changeset, result ->
|
||||
# Runs inside the transaction after the main action, only on success
|
||||
# Use for related database changes that depend on the result
|
||||
MyApp.Inventory.update_stock_levels(result, scope: context)
|
||||
{changeset, result}
|
||||
end)
|
||||
|> Ash.Changeset.after_transaction(fn changeset,
|
||||
{:ok, result} ->
|
||||
# Runs after the transaction completes (success or failure)
|
||||
# Use for notifications, external systems, etc.
|
||||
MyApp.Mailer.send_order_confirmation(result, scope: context)
|
||||
{changeset, result}
|
||||
|
||||
{:error, error} ->
|
||||
# Runs after the transaction completes (success or failure)
|
||||
# Use for notifications, external systems, etc.
|
||||
MyApp.Mailer.send_order_issue_notice(result, scope: context)
|
||||
{:error, error}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in resource:
|
||||
change {MyApp.Changes.ProcessOrder, []}
|
||||
```
|
||||
|
||||
## Atomic Changes
|
||||
|
||||
Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates.
|
||||
|
||||
**Why atomic matters:**
|
||||
- Avoids race conditions (e.g., incrementing a counter)
|
||||
- Better performance (no round-trip to load the record)
|
||||
- Required for bulk operations to work efficiently
|
||||
|
||||
**Built-in atomic changes:**
|
||||
```elixir
|
||||
# Increment a counter atomically
|
||||
change atomic_update(:view_count, expr(view_count + 1))
|
||||
|
||||
# Set a value using an expression
|
||||
change set_attribute(:updated_at, expr(now()))
|
||||
```
|
||||
|
||||
**Making custom changes atomic:**
|
||||
Implement the `atomic/3` callback to support atomic execution:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Changes.IncrementVersion do
|
||||
use Ash.Resource.Change
|
||||
|
||||
@impl true
|
||||
def change(changeset, _opts, _context) do
|
||||
# Fallback for non-atomic execution
|
||||
current = Ash.Changeset.get_attribute(changeset, :version) || 0
|
||||
Ash.Changeset.change_attribute(changeset, :version, current + 1)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic(_changeset, _opts, _context) do
|
||||
# Atomic implementation - runs in the database
|
||||
{:atomic, %{version: expr(coalesce(version, 0) + 1)}}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Using `require_atomic? false`
|
||||
|
||||
By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error.
|
||||
|
||||
**IMPORTANT:** When you see `require_atomic? false` on an action, carefully consider whether it is truly necessary. This option should be used sparingly.
|
||||
|
||||
**When `require_atomic? false` is needed:**
|
||||
- The action has `before_action` or `around_action` hooks that need to read or modify the record
|
||||
- A change reads the current record state (e.g., `Ash.Changeset.get_data/2`) and cannot be rewritten atomically
|
||||
- Complex validations that cannot be expressed as database expressions
|
||||
|
||||
**When `require_atomic? false` is NOT needed:**
|
||||
- Simple attribute transformations (these can usually be made atomic)
|
||||
- Setting timestamps or default values (use `expr(now())` instead)
|
||||
- Incrementing counters (use `atomic_update/2`)
|
||||
- After-action hooks (these don't prevent atomic execution)
|
||||
- After-transaction hooks (these don't prevent atomic execution)
|
||||
|
||||
```elixir
|
||||
actions do
|
||||
update :update do
|
||||
# AVOID unless truly necessary
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :increment_views do
|
||||
# GOOD - fully atomic, no need to disable
|
||||
change atomic_update(:view_count, expr(view_count + 1))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
If you find yourself adding `require_atomic? false`, first check if your changes and validations can be rewritten with `atomic/3` callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks.
|
||||
|
||||
## Custom Modules vs. Anonymous Functions
|
||||
|
||||
Prefer to put code in its own module and refer to that in changes, preparations, validations etc.
|
||||
|
||||
For example, prefer this:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _, _) do
|
||||
Ash.Changeset.before_action(changeset, fn changeset, _ ->
|
||||
slug = MyApp.Slug.get()
|
||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
change MyApp.MyDomain.MyResource.Changes.SlugifyName
|
||||
```
|
||||
|
||||
## Action Types
|
||||
|
||||
- **Read**: For retrieving records
|
||||
- **Create**: For creating records
|
||||
- **Update**: For changing records
|
||||
- **Destroy**: For removing records
|
||||
- **Generic**: For custom operations that don't fit the other types
|
||||
105
.claude/skills/ash-framework/references/aggregates.md
Normal file
105
.claude/skills/ash-framework/references/aggregates.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Aggregates
|
||||
|
||||
Aggregates allow you to retrieve summary information over groups of related data, like counts, sums, or averages. Define aggregates in the `aggregates` block of a resource.
|
||||
|
||||
Aggregates can work over relationships or directly over unrelated resources:
|
||||
|
||||
```elixir
|
||||
aggregates do
|
||||
# Related aggregates - use relationship path
|
||||
count :published_post_count, :posts do
|
||||
filter expr(published == true)
|
||||
end
|
||||
|
||||
sum :total_sales, :orders, :amount
|
||||
|
||||
exists :is_admin, :roles do
|
||||
filter expr(name == "admin")
|
||||
end
|
||||
|
||||
# Unrelated aggregates - use resource module directly
|
||||
count :matching_profiles_count, Profile do
|
||||
filter expr(name == parent(name))
|
||||
end
|
||||
|
||||
sum :total_report_score, Report, :score do
|
||||
filter expr(author_name == parent(name))
|
||||
end
|
||||
|
||||
exists :has_reports, Report do
|
||||
filter expr(author_name == parent(name))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
For unrelated aggregates, use `parent/1` to reference fields from the source resource.
|
||||
|
||||
## Aggregate Types
|
||||
|
||||
- **count**: Counts related items meeting criteria
|
||||
- **sum**: Sums a field across related items
|
||||
- **exists**: Returns boolean indicating if matching related items exist (also supports unrelated resources)
|
||||
- **first**: Gets the first related value matching criteria
|
||||
- **list**: Lists the related values for a specific field
|
||||
- **max**: Gets the maximum value of a field
|
||||
- **min**: Gets the minimum value of a field
|
||||
- **avg**: Gets the average value of a field
|
||||
|
||||
## Using Aggregates
|
||||
|
||||
```elixir
|
||||
# Using code interface options (preferred)
|
||||
users = MyDomain.list_users!(
|
||||
load: [:published_post_count, :total_sales],
|
||||
query: [
|
||||
filter: [published_post_count: [greater_than: 5]],
|
||||
sort: [published_post_count: :desc]
|
||||
]
|
||||
)
|
||||
|
||||
# Manual query building (for complex cases)
|
||||
User |> Ash.Query.filter(published_post_count > 5) |> Ash.read!()
|
||||
|
||||
# Loading on existing records
|
||||
Ash.load!(users, :published_post_count)
|
||||
```
|
||||
|
||||
### Join Filters
|
||||
|
||||
For complex aggregates involving multiple relationships, use join filters:
|
||||
|
||||
```elixir
|
||||
aggregates do
|
||||
sum :redeemed_deal_amount, [:redeems, :deal], :amount do
|
||||
# Filter on the aggregate as a whole
|
||||
filter expr(redeems.redeemed == true)
|
||||
|
||||
# Apply filters to specific relationship steps
|
||||
join_filter :redeems, expr(redeemed == true)
|
||||
join_filter [:redeems, :deal], expr(active == parent(require_active))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Inline Aggregates
|
||||
|
||||
Use aggregates inline within expressions:
|
||||
|
||||
```elixir
|
||||
# Related inline aggregates
|
||||
calculate :grade_percentage, :decimal, expr(
|
||||
count(answers, query: [filter: expr(correct == true)]) * 100 /
|
||||
count(answers)
|
||||
)
|
||||
|
||||
# Unrelated inline aggregates
|
||||
calculate :profile_count, :integer, expr(
|
||||
count(Profile, filter: expr(name == parent(name)))
|
||||
)
|
||||
|
||||
calculate :stats, :map, expr(%{
|
||||
profiles: count(Profile, filter: expr(active == true)),
|
||||
reports: count(Report, filter: expr(author_name == parent(name))),
|
||||
has_active_profile: exists(Profile, active == true and name == parent(name))
|
||||
})
|
||||
```
|
||||
5
.claude/skills/ash-framework/references/ash.md
Normal file
5
.claude/skills/ash-framework/references/ash.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rules for working with Ash
|
||||
|
||||
## Understanding Ash
|
||||
|
||||
Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.
|
||||
516
.claude/skills/ash-framework/references/ash_ai.md
Normal file
516
.claude/skills/ash-framework/references/ash_ai.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# Rules for working with Ash AI
|
||||
|
||||
## Understanding Ash AI
|
||||
|
||||
Ash AI is an extension for the Ash framework that integrates AI capabilities with Ash resources. It provides tools for vectorization, embedding generation, LLM interaction, and tooling for AI models.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Vectorization**: Convert text attributes into vector embeddings for semantic search
|
||||
- **AI Tools**: Expose Ash actions as tools for LLMs
|
||||
- **Prompt-backed Actions**: Create actions where the implementation is handled by an LLM
|
||||
- **MCP Server**: Expose your tools to Machine Context Protocol clients
|
||||
|
||||
## Vectorization
|
||||
|
||||
Vectorization allows you to convert text data into embeddings that can be used for semantic search.
|
||||
|
||||
### Setting Up Vectorization
|
||||
|
||||
Add vectorization to a resource by including the `AshAi` extension and defining a vectorize block:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Artist do
|
||||
use Ash.Resource, extensions: [AshAi]
|
||||
|
||||
vectorize do
|
||||
# For creating a single vector from multiple attributes
|
||||
full_text do
|
||||
text(fn record ->
|
||||
"""
|
||||
Name: #{record.name}
|
||||
Biography: #{record.biography}
|
||||
"""
|
||||
end)
|
||||
|
||||
# Optional - only rebuild embeddings when these attributes change
|
||||
used_attributes [:name, :biography]
|
||||
end
|
||||
|
||||
# Choose a strategy for updating embeddings
|
||||
strategy :ash_oban
|
||||
|
||||
# Specify your embedding model implementation
|
||||
embedding_model MyApp.OpenAiEmbeddingModel
|
||||
end
|
||||
|
||||
# Rest of resource definition...
|
||||
end
|
||||
```
|
||||
|
||||
### Embedding Models
|
||||
|
||||
Create a module that implements the `AshAi.EmbeddingModel` behaviour to generate embeddings:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.OpenAiEmbeddingModel do
|
||||
use AshAi.EmbeddingModel
|
||||
|
||||
@impl true
|
||||
def dimensions(_opts), do: 3072
|
||||
|
||||
@impl true
|
||||
def generate(texts, _opts) do
|
||||
api_key = System.fetch_env!("OPEN_AI_API_KEY")
|
||||
|
||||
headers = [
|
||||
{"Authorization", "Bearer #{api_key}"},
|
||||
{"Content-Type", "application/json"}
|
||||
]
|
||||
|
||||
body = %{
|
||||
"input" => texts,
|
||||
"model" => "text-embedding-3-large"
|
||||
}
|
||||
|
||||
response =
|
||||
Req.post!("https://api.openai.com/v1/embeddings",
|
||||
json: body,
|
||||
headers: headers
|
||||
)
|
||||
|
||||
case response.status do
|
||||
200 ->
|
||||
response.body["data"]
|
||||
|> Enum.map(fn %{"embedding" => embedding} -> embedding end)
|
||||
|> then(&{:ok, &1})
|
||||
|
||||
_status ->
|
||||
{:error, response.body}
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Vectorization Strategies
|
||||
|
||||
Choose the appropriate strategy based on your performance requirements:
|
||||
|
||||
1. **`:after_action`** (default): Updates embeddings synchronously after each create and update action
|
||||
- Simple but can make your app slow
|
||||
- Not recommended for production use with many records
|
||||
|
||||
2. **`:ash_oban`**: Updates embeddings asynchronously using Ash Oban
|
||||
- Requires `ash_oban` extension
|
||||
- Better for production use
|
||||
|
||||
3. **`:manual`**: No automatic updates; you control when embeddings are updated
|
||||
- Most flexible but requires you to manage when to update embeddings
|
||||
|
||||
### Using the Vectors for Search
|
||||
|
||||
Use vector expressions in filters and sorts:
|
||||
|
||||
```elixir
|
||||
read :semantic_search do
|
||||
argument :query, :string, allow_nil?: false
|
||||
|
||||
prepare before_action(fn query, context ->
|
||||
case MyApp.OpenAiEmbeddingModel.generate([query.arguments.query], []) do
|
||||
{:ok, [search_vector]} ->
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
vector_cosine_distance(full_text_vector, ^search_vector) < 0.5
|
||||
)
|
||||
|> Ash.Query.sort([
|
||||
{
|
||||
calc(vector_cosine_distance(
|
||||
full_text_vector,
|
||||
^search_vector
|
||||
)),
|
||||
:asc
|
||||
}
|
||||
])
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
### Authorization for Vectorization
|
||||
|
||||
If you're using policies, add a bypass to allow embedding updates:
|
||||
|
||||
```elixir
|
||||
bypass action(:ash_ai_update_embeddings) do
|
||||
authorize_if AshAi.Checks.ActorIsAshAi
|
||||
end
|
||||
```
|
||||
|
||||
## AI Tools
|
||||
|
||||
Expose your Ash actions as tools for LLMs to use by configuring them in your domain:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Blog do
|
||||
use Ash.Domain, extensions: [AshAi]
|
||||
|
||||
tools do
|
||||
tool :read_posts, MyApp.Blog.Post, :read do
|
||||
description "customize the tool description"
|
||||
end
|
||||
tool :create_post, MyApp.Blog.Post, :create
|
||||
tool :publish_post, MyApp.Blog.Post, :publish
|
||||
tool :read_comments, MyApp.Blog.Comment, :read
|
||||
end
|
||||
|
||||
# Rest of domain definition...
|
||||
end
|
||||
```
|
||||
|
||||
### Tool Data Access Rules
|
||||
|
||||
Tools have different access levels for different operations:
|
||||
|
||||
1. **Filtering/Sorting/Aggregation**: Only attributes with `public?: true` can be used
|
||||
2. **Arguments**: Only action arguments with `public?: true` are exposed to tools
|
||||
3. **Response data**: Public attributes are returned by default
|
||||
4. **Loading data**: The `load` option is used to include relationships, calculations, or additional attributes in responses (both public and private)
|
||||
|
||||
Example:
|
||||
|
||||
```elixir
|
||||
# Resource definition
|
||||
defmodule MyApp.Blog.Post do
|
||||
attributes do
|
||||
attribute :title, :string, public?: true
|
||||
attribute :content, :string, public?: true
|
||||
attribute :internal_notes, :string # Default is public?: false
|
||||
attribute :view_count, :integer, public?: true
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, MyApp.Accounts.User, public?: true
|
||||
end
|
||||
end
|
||||
|
||||
# Tool definition
|
||||
tools do
|
||||
# Returns only public attributes (title, content, view_count)
|
||||
tool :read_posts, MyApp.Blog.Post, :read
|
||||
|
||||
# Returns public attributes plus loaded fields (including private ones)
|
||||
tool :read_posts_with_all_details, MyApp.Blog.Post, :read do
|
||||
load [:author, :internal_notes]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
With this configuration:
|
||||
- Tools can only filter/sort by `title`, `content`, and `view_count`
|
||||
- `internal_notes` cannot be used for filtering, sorting, or aggregation
|
||||
- `internal_notes` CAN be returned when explicitly loaded via the `load` option
|
||||
- The `author` relationship can include both public and private attributes when loaded
|
||||
|
||||
This provides flexibility while maintaining control over data access:
|
||||
- Private data is protected from queries and operations
|
||||
- Private data can still be included in responses when explicitly loaded
|
||||
- The `load` option serves dual purposes: loading relationships/calculations and making any loaded attributes visible (including private ones)
|
||||
|
||||
### Using Tools in LangChain
|
||||
|
||||
Add your Ash AI tools to a LangChain chain:
|
||||
|
||||
```elixir
|
||||
chain =
|
||||
%{
|
||||
llm: LangChain.ChatModels.ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
verbose: true
|
||||
}
|
||||
|> LangChain.Chains.LLMChain.new!()
|
||||
|> AshAi.setup_ash_ai(otp_app: :my_app, tools: [:list, :of, :tools])
|
||||
```
|
||||
|
||||
## Structured Outputs (Prompt-Backed Actions)
|
||||
|
||||
Create actions that use LLMs for their implementation:
|
||||
|
||||
```elixir
|
||||
action :analyze_sentiment, :atom do
|
||||
constraints one_of: [:positive, :negative]
|
||||
|
||||
description """
|
||||
Analyzes the sentiment of a given piece of text to determine if it is overall positive or negative.
|
||||
"""
|
||||
|
||||
argument :text, :string do
|
||||
allow_nil? false
|
||||
description "The text for analysis"
|
||||
end
|
||||
|
||||
run prompt(
|
||||
LangChain.ChatModels.ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
# Allow the model to use tools
|
||||
tools: true,
|
||||
# Or restrict to specific tools
|
||||
# tools: [:list, :of, :tool, :names],
|
||||
# Optionally provide a custom prompt template
|
||||
# prompt: "Analyze the sentiment of the following text: <%= @input.arguments.text %>"
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
### Structured Outputs with Custom Types
|
||||
|
||||
The action's return type provides the JSON schema automatically. For complex structured outputs, you can use any Ash type, including `Ash.TypedStruct`:
|
||||
|
||||
```elixir
|
||||
# Example using Ash.TypedStruct
|
||||
defmodule JobListing do
|
||||
use Ash.TypedStruct
|
||||
|
||||
typed_struct do
|
||||
field :title, :string, allow_nil?: false
|
||||
field :company, :string, allow_nil?: false
|
||||
field :location, :string
|
||||
field :salary_range, :string
|
||||
field :requirements, {:array, :string}
|
||||
end
|
||||
end
|
||||
|
||||
# Use it as the return type for your action
|
||||
action :parse_raw, JobListing do
|
||||
argument :raw_content, :string, allow_nil?: false
|
||||
|
||||
run prompt(
|
||||
fn _input, _context ->
|
||||
LangChain.ChatModels.ChatOpenAI.new!(%{
|
||||
model: "gpt-4o-mini",
|
||||
api_key: System.get_env("OPENAI_API_KEY"),
|
||||
temperature: 0.1
|
||||
})
|
||||
end,
|
||||
prompt: """
|
||||
Parse this job listing into structured data following the exact schema.
|
||||
Extract all available information and return as JSON:
|
||||
|
||||
<%= @input.arguments.raw_content %>
|
||||
""",
|
||||
tools: false
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
### Dynamic LLM Configuration
|
||||
|
||||
For runtime configuration (like environment variables), use a function to define the LLM:
|
||||
|
||||
```elixir
|
||||
action :analyze_sentiment, :atom do
|
||||
argument :text, :string, allow_nil?: false
|
||||
|
||||
run prompt(
|
||||
fn _input, _context ->
|
||||
LangChain.ChatModels.ChatOpenAI.new!(%{
|
||||
model: "gpt-4o",
|
||||
# this can also be configured in application config, see langchain docs for more.
|
||||
api_key: System.get_env("OPENAI_API_KEY"),
|
||||
endpoint: System.get_env("OPENAI_ENDPOINT")
|
||||
})
|
||||
end,
|
||||
tools: false
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
The function receives:
|
||||
1. `input` - The action input
|
||||
2. `context` - The execution context
|
||||
|
||||
### Prompt Format Options
|
||||
|
||||
The `prompt` option supports multiple formats for maximum flexibility:
|
||||
|
||||
#### 1. String (EEx Template)
|
||||
Simple string templates with access to `@input` and `@context`:
|
||||
|
||||
```elixir
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
prompt: "Analyze the sentiment of: <%= @input.arguments.text %>"
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. System/User Tuple
|
||||
Separate system and user messages (both support EEx templates):
|
||||
|
||||
```elixir
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
prompt: {"You are a sentiment analyzer", "Analyze: <%= @input.arguments.text %>"}
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. LangChain Messages List
|
||||
For complex multi-turn conversations or image analysis:
|
||||
|
||||
```elixir
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
prompt: [
|
||||
Message.new_system!("You are an expert assistant"),
|
||||
Message.new_user!("Hello, how can you help me?"),
|
||||
Message.new_assistant!("I can help with various tasks"),
|
||||
Message.new_user!("Great! Please analyze this data")
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
For image analysis with templates:
|
||||
|
||||
```elixir
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
prompt: [
|
||||
Message.new_system!("You are an expert at image analysis"),
|
||||
Message.new_user!([
|
||||
PromptTemplate.from_template!("Extra context: <%= @input.arguments.context %>"),
|
||||
ContentPart.image!("<%= @input.arguments.image_data %>", media: :jpg, detail: "low")
|
||||
])
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
#### 4. Dynamic Function
|
||||
Return any of the above formats dynamically based on input:
|
||||
|
||||
```elixir
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
prompt: fn input, context ->
|
||||
base = [Message.new_system!("You are helpful")]
|
||||
|
||||
history = input.arguments.conversation_history
|
||||
|> Enum.map(fn %{"role" => role, "content" => content} ->
|
||||
case role do
|
||||
"user" -> Message.new_user!(content)
|
||||
"assistant" -> Message.new_assistant!(content)
|
||||
end
|
||||
end)
|
||||
|
||||
base ++ history
|
||||
end
|
||||
)
|
||||
```
|
||||
|
||||
#### Template Processing
|
||||
|
||||
- **String prompts**: Processed as EEx templates with `@input` and `@context` variables
|
||||
- **Messages with PromptTemplate**: Processed using LangChain's `apply_prompt_templates`
|
||||
- **Functions**: Can return any supported format for dynamic generation
|
||||
|
||||
If no custom prompt is provided, a default template is used that includes the action name, description, and argument details.
|
||||
|
||||
### Adapters
|
||||
|
||||
Adapters control how the LLM is called to generate structured outputs. AshAi automatically selects the appropriate adapter based on your LLM, but you can override this with the `:adapter` option.
|
||||
|
||||
#### Default Adapter Selection
|
||||
|
||||
- **OpenAI API endpoints**: Uses `AshAi.Actions.Prompt.Adapter.StructuredOutput` (leverages OpenAI's structured output features)
|
||||
- **Non-OpenAI endpoints**: Uses `AshAi.Actions.Prompt.Adapter.RequestJson` (requests JSON in the prompt)
|
||||
- **Anthropic**: Uses `AshAi.Actions.Prompt.Adapter.CompletionTool` (uses tool calling for structured outputs)
|
||||
|
||||
#### Custom Adapter Configuration
|
||||
|
||||
You can specify a custom adapter or adapter options:
|
||||
|
||||
```elixir
|
||||
# Use a specific adapter
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
adapter: AshAi.Actions.Prompt.Adapter.RequestJson,
|
||||
tools: false
|
||||
)
|
||||
|
||||
# Use an adapter with custom options
|
||||
run prompt(
|
||||
ChatOpenAI.new!(%{model: "gpt-4o"}),
|
||||
adapter: {AshAi.Actions.Prompt.Adapter.StructuredOutput, [some_option: :value]},
|
||||
tools: false
|
||||
)
|
||||
```
|
||||
|
||||
#### Available Adapters
|
||||
|
||||
- **`StructuredOutput`**: Best for OpenAI models, uses native structured output capabilities
|
||||
- **`RequestJson`**: Works with any model, requests JSON format in the prompt
|
||||
- **`CompletionTool`**: Uses tool calling to generate structured outputs, good for models that support function calling
|
||||
|
||||
### Best Practices for Prompt-Backed Actions
|
||||
|
||||
- Write clear, detailed descriptions for the action and its arguments
|
||||
- Use constraints when appropriate to restrict outputs
|
||||
- Choose the appropriate prompt format for your use case:
|
||||
- Simple string templates for basic prompts
|
||||
- System/user tuples for role-based interactions
|
||||
- Message lists for complex conversations or multi-modal inputs
|
||||
- Functions for dynamic prompt generation
|
||||
- Test thoroughly with different inputs to ensure reliable results
|
||||
|
||||
## Model Context Protocol (MCP) Server
|
||||
|
||||
### Development MCP Server
|
||||
|
||||
For development environments, add the dev MCP server to your Phoenix endpoint:
|
||||
|
||||
```elixir
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
|
||||
plug AshAi.Mcp.Dev,
|
||||
protocol_version_statement: "2024-11-05",
|
||||
otp_app: :your_app
|
||||
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
end
|
||||
```
|
||||
|
||||
### Production MCP Server
|
||||
|
||||
For production environments, set up authentication and add the MCP router:
|
||||
|
||||
```elixir
|
||||
# Add api_key strategy to your auth pipeline
|
||||
pipeline :mcp do
|
||||
plug AshAuthentication.Strategy.ApiKey.Plug,
|
||||
resource: YourApp.Accounts.User,
|
||||
required?: false # Set to true if all tools require authentication
|
||||
end
|
||||
|
||||
# In your router
|
||||
scope "/mcp" do
|
||||
pipe_through :mcp
|
||||
|
||||
forward "/", AshAi.Mcp.Router,
|
||||
tools: [
|
||||
# List your tools here
|
||||
:read_posts,
|
||||
:create_post,
|
||||
:analyze_sentiment
|
||||
],
|
||||
protocol_version_statement: "2024-11-05",
|
||||
otp_app: :my_app
|
||||
end
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
When testing AI components:
|
||||
- Mock embedding model responses for consistent test results
|
||||
- Test vector search with known embeddings
|
||||
- For prompt-backed actions, consider using deterministic test models
|
||||
- Verify tool access and permissions work as expected
|
||||
372
.claude/skills/ash-framework/references/ash_authentication.md
Normal file
372
.claude/skills/ash-framework/references/ash_authentication.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# AshAuthentication Usage Rules
|
||||
|
||||
## Core Concepts
|
||||
- **Strategies**: password, OAuth2, magic_link, api_key authentication methods
|
||||
- **Tokens**: JWT for stateless authentication
|
||||
- **UserIdentity**: links users to OAuth2 providers
|
||||
- **Add-ons**: confirmation, logout-everywhere functionality
|
||||
- **Actions**: auto-generated by strategies (register, sign_in, etc.), can be overridden on the resource
|
||||
|
||||
## Key Principles
|
||||
- Always use secrets management - never hardcode credentials
|
||||
- Enable tokens for magic_link, confirmation, OAuth2
|
||||
- UserIdentity resource optional for OAuth2 (required for multiple providers per user)
|
||||
- API keys require strict policy controls and expiration management
|
||||
- Use prefixes for API keys to enable secret scanning compliance
|
||||
- Check existing strategies: `AshAuthentication.Info.strategies/1`
|
||||
|
||||
## Strategy Selection
|
||||
|
||||
**Password** - Email/password authentication
|
||||
- Requires: `:email`, `:hashed_password` attributes, unique identity
|
||||
|
||||
**Magic Link** - Passwordless email authentication
|
||||
- Requires: `:email` attribute, sender implementation, tokens enabled
|
||||
|
||||
**API Key** - Token-based authentication for APIs
|
||||
- Requires: API key resource, relationship to user, sign-in action
|
||||
|
||||
**OAuth2** - Social/enterprise login (GitHub, Google, Auth0, Apple, OIDC, Slack)
|
||||
- Requires: custom actions, secrets
|
||||
- Optional: UserIdentity resource (for multiple providers per user)
|
||||
|
||||
## Password Strategy
|
||||
|
||||
```elixir
|
||||
authentication do
|
||||
strategies do
|
||||
password :password do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
resettable do
|
||||
sender MyApp.PasswordResetSender
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Required attributes:
|
||||
attributes do
|
||||
attribute :email, :ci_string, allow_nil?: false, public?: true
|
||||
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
end
|
||||
```
|
||||
|
||||
## Magic Link Strategy
|
||||
|
||||
```elixir
|
||||
authentication do
|
||||
strategies do
|
||||
magic_link do
|
||||
identity_field :email
|
||||
sender MyApp.MagicLinkSender
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Sender implementation required:
|
||||
defmodule MyApp.MagicLinkSender do
|
||||
use AshAuthentication.Sender
|
||||
|
||||
def send(user_or_email, token, _opts) do
|
||||
MyApp.Emails.deliver_magic_link(user_or_email, token)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## API Key Strategy
|
||||
|
||||
```elixir
|
||||
# 1. Create API key resource
|
||||
defmodule MyApp.Accounts.ApiKey do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:user_id, :expires_at]
|
||||
change {AshAuthentication.Strategy.ApiKey.GenerateApiKey, prefix: :myapp, hash: :api_key_hash}
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :api_key_hash, :binary, allow_nil?: false, sensitive?: true
|
||||
attribute :expires_at, :utc_datetime_usec, allow_nil?: false
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :user, MyApp.Accounts.User, allow_nil?: false
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :valid, :boolean, expr(expires_at > now())
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_api_key, [:api_key_hash]
|
||||
end
|
||||
|
||||
policies do
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 2. Add strategy to user resource
|
||||
authentication do
|
||||
strategies do
|
||||
api_key do
|
||||
api_key_relationship :valid_api_keys
|
||||
api_key_hash_attribute :api_key_hash
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 3. Add relationship to user
|
||||
relationships do
|
||||
has_many :valid_api_keys, MyApp.Accounts.ApiKey do
|
||||
filter expr(valid)
|
||||
end
|
||||
end
|
||||
|
||||
# 4. Add sign-in action to user
|
||||
actions do
|
||||
read :sign_in_with_api_key do
|
||||
argument :api_key, :string, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Security considerations:**
|
||||
- API keys are hashed for storage security
|
||||
- Use policies to restrict API key access to specific actions
|
||||
- Check `user.__metadata__[:using_api_key?]` to detect API key authentication
|
||||
- Access the API key via `user.__metadata__[:api_key]` for permission checks
|
||||
|
||||
## OAuth2 Strategies
|
||||
|
||||
**Supported providers:** github, google, auth0, apple, oidc, slack
|
||||
|
||||
**Required for all OAuth2:**
|
||||
- Custom `register_with_[provider]` action
|
||||
- Secrets management
|
||||
- Tokens enabled
|
||||
|
||||
**Optional for all OAuth2:**
|
||||
- UserIdentity resource (for multiple providers per user)
|
||||
|
||||
### OAuth2 Configuration Pattern
|
||||
```elixir
|
||||
# Strategy configuration
|
||||
authentication do
|
||||
strategies do
|
||||
github do # or google, auth0, apple, oidc, slack
|
||||
client_id MyApp.Secrets
|
||||
client_secret MyApp.Secrets
|
||||
redirect_uri MyApp.Secrets
|
||||
# auth0 also needs: base_url
|
||||
# apple also needs: team_id, private_key_id, private_key_path
|
||||
# oidc also needs: openid_configuration_uri
|
||||
identity_resource MyApp.Accounts.UserIdentity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Required action (replace 'github' with provider name)
|
||||
actions do
|
||||
create :register_with_github do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
upsert_identity :unique_email
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
|
||||
# If UserIdentity resource is being used
|
||||
change AshAuthentication.Strategy.OAuth2.IdentityChange
|
||||
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
Ash.Changeset.change_attributes(changeset, Map.take(user_info, ["email"]))
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Add-ons
|
||||
|
||||
### Confirmation
|
||||
```elixir
|
||||
authentication do
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource MyApp.Accounts.Token
|
||||
end
|
||||
|
||||
add_ons do
|
||||
confirmation :confirm do
|
||||
monitor_fields [:email]
|
||||
sender MyApp.ConfirmationSender
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Log Out Everywhere
|
||||
```elixir
|
||||
authentication do
|
||||
tokens do
|
||||
store_all_tokens? true
|
||||
end
|
||||
|
||||
add_ons do
|
||||
log_out_everywhere do
|
||||
apply_on_password_change? true
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Working with Authentication
|
||||
|
||||
### Strategy Protocol
|
||||
```elixir
|
||||
# Get and use strategies
|
||||
strategy = AshAuthentication.Info.strategy!(MyApp.User, :password)
|
||||
{:ok, user} = AshAuthentication.Strategy.action(strategy, :sign_in, params)
|
||||
|
||||
# List strategies
|
||||
strategies = AshAuthentication.Info.strategies(MyApp.User)
|
||||
```
|
||||
|
||||
### Token Operations
|
||||
```elixir
|
||||
# User/subject conversion
|
||||
subject = AshAuthentication.user_to_subject(user)
|
||||
{:ok, user} = AshAuthentication.subject_to_user(subject, MyApp.User)
|
||||
|
||||
# Token management
|
||||
AshAuthentication.TokenResource.revoke(MyApp.Token, token)
|
||||
```
|
||||
|
||||
### Policies
|
||||
```elixir
|
||||
policies do
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Common Implementation Patterns
|
||||
|
||||
### Pattern: Multiple Authentication Methods
|
||||
When users need multiple ways to authenticate:
|
||||
|
||||
```elixir
|
||||
authentication do
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource MyApp.Accounts.Token
|
||||
end
|
||||
|
||||
strategies do
|
||||
password :password do
|
||||
identity_field :email
|
||||
hashed_password_field :hashed_password
|
||||
end
|
||||
|
||||
github do
|
||||
client_id MyApp.Secrets
|
||||
client_secret MyApp.Secrets
|
||||
redirect_uri MyApp.Secrets
|
||||
identity_resource MyApp.Accounts.UserIdentity
|
||||
end
|
||||
|
||||
magic_link do
|
||||
identity_field :email
|
||||
sender MyApp.MagicLinkSender
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Pattern: OAuth2 with User Registration
|
||||
When new users can register via OAuth2:
|
||||
|
||||
```elixir
|
||||
actions do
|
||||
create :register_with_github do
|
||||
argument :user_info, :map, allow_nil?: false
|
||||
argument :oauth_tokens, :map, allow_nil?: false
|
||||
upsert? true
|
||||
upsert_identity :email
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:email, user_info["email"])
|
||||
|> Ash.Changeset.change_attribute(:name, user_info["name"])
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Pattern: Custom Token Configuration
|
||||
When you need specific token behavior:
|
||||
|
||||
```elixir
|
||||
authentication do
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource MyApp.Accounts.Token
|
||||
signing_secret MyApp.Secrets
|
||||
token_lifetime {24, :hours}
|
||||
store_all_tokens? true # For logout-everywhere functionality
|
||||
require_token_presence_for_authentication? false
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Customizing Authentication Actions
|
||||
|
||||
When customizing generated authentication actions (register, sign_in, etc.):
|
||||
|
||||
**Key Security Rules:**
|
||||
- Always mark credentials with `sensitive?: true` (passwords, API keys, tokens)
|
||||
- Use `public?: false` for internal fields and highly sensitive PII
|
||||
- Use `public?: true` for identity fields and UI display data
|
||||
- Include required authentication changes (`GenerateTokenChange`, `HashPasswordChange`, etc.)
|
||||
|
||||
**Argument Handling:**
|
||||
- All arguments must be used in `accept` or `change set_attribute()`
|
||||
- Use `allow_nil?: false` for required arguments
|
||||
- OAuth2 data must be extracted in changes, not accepted directly
|
||||
|
||||
**Example Custom Registration:**
|
||||
```elixir
|
||||
create :register_with_password do
|
||||
argument :password, :string, allow_nil?: false, sensitive?: true
|
||||
argument :first_name, :string, allow_nil?: false
|
||||
|
||||
accept [:email, :first_name]
|
||||
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
end
|
||||
```
|
||||
|
||||
For more guidance, see the "Customizing Authentication Actions" section in the getting started guide.
|
||||
5
.claude/skills/ash-framework/references/ash_graphql.md
Normal file
5
.claude/skills/ash-framework/references/ash_graphql.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rules for working with AshGraphql
|
||||
|
||||
## Understanding AshGraphql
|
||||
|
||||
AshGraphql is a package for integrating Ash Framework with GraphQL. It provides tools for generating GraphQL types, queries, mutations, and subscriptions from your Ash resources. AshGraphql leverages Absinthe under the hood to create a seamless integration between your Ash resources and GraphQL API.
|
||||
109
.claude/skills/ash-framework/references/ash_json_api.md
Normal file
109
.claude/skills/ash-framework/references/ash_json_api.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Rules for working with AshJsonApi
|
||||
|
||||
## Understanding AshJsonApi
|
||||
|
||||
AshJsonApi is a package for integrating Ash Framework with the JSON:API specification. It provides tools for generating JSON:API compliant endpoints from your Ash resources. AshJsonApi allows you to expose your Ash resources through a standardized RESTful API, supporting all JSON:API features like filtering, sorting, pagination, includes, and relationships.
|
||||
|
||||
## Domain Configuration
|
||||
|
||||
AshJsonApi works by extending your Ash domains and resources with JSON:API capabilities. First, add the AshJsonApi extension to your domain.
|
||||
|
||||
### Setting Up Your Domain
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Blog do
|
||||
use Ash.Domain,
|
||||
extensions: [
|
||||
AshJsonApi.Domain
|
||||
]
|
||||
|
||||
json_api do
|
||||
# Define JSON:API-specific settings for this domain
|
||||
authorize? true
|
||||
|
||||
# You can define routes at the domain level
|
||||
routes do
|
||||
base_route "/posts", MyApp.Blog.Post do
|
||||
get :read
|
||||
index :read
|
||||
post :create
|
||||
patch :update
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources do
|
||||
resource MyApp.Blog.Post
|
||||
resource MyApp.Blog.Comment
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Resource Configuration
|
||||
|
||||
Each resource that you want to expose via JSON:API needs to include the AshJsonApi.Resource extension.
|
||||
|
||||
### Setting Up Resources
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Blog.Post do
|
||||
use Ash.Resource,
|
||||
domain: MyApp.Blog,
|
||||
extensions: [AshJsonApi.Resource]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :title, :string
|
||||
attribute :body, :string
|
||||
attribute :published, :boolean
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, MyApp.Accounts.User
|
||||
has_many :comments, MyApp.Blog.Comment
|
||||
end
|
||||
|
||||
json_api do
|
||||
# The JSON:API type name (required)
|
||||
type "post"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:create, :read, :update, :destroy]
|
||||
|
||||
read :list_published do
|
||||
filter expr(published == true)
|
||||
end
|
||||
|
||||
update :publish do
|
||||
accept []
|
||||
change set_attribute(:published, true)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Route Types
|
||||
|
||||
AshJsonApi supports various route types according to the JSON:API spec:
|
||||
|
||||
- `get` - Fetch a single resource by ID
|
||||
- `index` - List resources, with support for filtering, sorting, and pagination
|
||||
- `post` - Create a new resource
|
||||
- `patch` - Update an existing resource
|
||||
- `delete` - Destroy an existing resource
|
||||
- `related` - Fetch related resources (e.g., `/posts/123/comments`)
|
||||
- `relationship` - Fetch relationship data (e.g., `/posts/123/relationships/comments`)
|
||||
- `post_to_relationship` - Add to a relationship
|
||||
- `patch_relationship` - Replace a relationship
|
||||
- `delete_from_relationship` - Remove from a relationship
|
||||
|
||||
## JSON:API Pagination, Filtering, and Sorting
|
||||
|
||||
AshJsonApi supports standard JSON:API query parameters:
|
||||
|
||||
- Filter: `?filter[attribute]=value`
|
||||
- Sort: `?sort=attribute,-other_attribute` (descending with `-`)
|
||||
- Pagination: `?page[number]=2&page[size]=10`
|
||||
- Includes: `?include=author,comments.author`
|
||||
5
.claude/skills/ash-framework/references/ash_phoenix.md
Normal file
5
.claude/skills/ash-framework/references/ash_phoenix.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rules for working with AshPhoenix
|
||||
|
||||
## Understanding AshPhoenix
|
||||
|
||||
AshPhoenix is a package for integrating Ash Framework with Phoenix Framework. It provides tools for integrating with Phoenix forms (`AshPhoenix.Form`), Phoenix LiveViews (`AshPhoenix.LiveView`), and more. AshPhoenix makes it seamless to use Phoenix's powerful UI capabilities with Ash's data management features.
|
||||
7
.claude/skills/ash-framework/references/ash_postgres.md
Normal file
7
.claude/skills/ash-framework/references/ash_postgres.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Rules for working with AshPostgres
|
||||
|
||||
## Understanding AshPostgres
|
||||
|
||||
AshPostgres is the PostgreSQL data layer for Ash Framework. It's the most fully-featured Ash data layer and should be your default choice unless you have specific requirements for another data layer. Any PostgreSQL version higher than 13 is fully supported.
|
||||
|
||||
Remember that using AshPostgres provides a full-featured PostgreSQL data layer for your Ash application, giving you both the structure and declarative approach of Ash along with the power and flexibility of PostgreSQL.
|
||||
412
.claude/skills/ash-framework/references/ash_typescript.md
Normal file
412
.claude/skills/ash-framework/references/ash_typescript.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# AshTypescript Usage Rules
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**Critical**: Add `AshTypescript.Rpc` extension to domain, run `mix ash_typescript.codegen`
|
||||
**Authentication**: Use `buildCSRFHeaders()` for Phoenix CSRF protection
|
||||
**Controller Routes**: Use `AshTypescript.TypedController` for controller-style actions with `conn` access
|
||||
**Typed Channels**: Use `AshTypescript.TypedChannel` for typed PubSub event subscriptions
|
||||
**Validation**: Always verify generated TypeScript compiles
|
||||
|
||||
## Essential Syntax Table
|
||||
|
||||
| Pattern | Syntax | Example |
|
||||
|---------|--------|---------|
|
||||
| **Domain Setup** | `use Ash.Domain, extensions: [AshTypescript.Rpc]` | Required extension |
|
||||
| **RPC Action** | `rpc_action :name, :action_type` | `rpc_action :list_todos, :read` |
|
||||
| **Basic Call** | `functionName({ fields: [...] })` | `listTodos({ fields: ["id", "title"] })` |
|
||||
| **Field Selection** | `["field1", {"nested": ["field2"]}]` | Relationships in objects |
|
||||
| **Union Fields** | `{ unionField: ["member1", {"member2": [...]}] }` | Selective union member access |
|
||||
| **Calculation (no args)** | `{ calc: ["field1", ...] }` | Simple nested syntax |
|
||||
| **Calculation (with args)** | `{ calc: { args: {...}, fields: [...] } }` | Args + fields object |
|
||||
| **Filter Syntax** | `{ field: { eq: value } }` | Always use operator objects |
|
||||
| **Sort String** | `"-field1,field2"` | Dash prefix = descending |
|
||||
| **CSRF Headers** | `headers: buildCSRFHeaders()` | Phoenix CSRF protection |
|
||||
| **Input Args** | `input: { argName: value }` | Action arguments |
|
||||
| **Identity (PK)** | `identity: "id-123"` | Primary key lookup |
|
||||
| **Identity (Named)** | `identity: { email: "a@b.com" }` | Named identity lookup |
|
||||
| **Identities Config** | `identities: [:_primary_key, :email]` | Allowed lookup methods |
|
||||
| **Actor-Scoped** | `identities: []` | No identity param needed |
|
||||
| **Get Action** | `get?: true` or `get_by: [:email]` | Single record lookup |
|
||||
| **Not Found** | `not_found_error?: false` | Return null instead of error |
|
||||
| **Custom Fetch** | `customFetch: myFetchFn` | Replace native fetch |
|
||||
| **Pagination** | `page: { limit: 10 }` | Offset/keyset pagination |
|
||||
| **Disable Filter** | `enable_filter?: false` | Disable client filtering |
|
||||
| **Disable Sort** | `enable_sort?: false` | Disable client sorting |
|
||||
| **Allowed Loads** | `allowed_loads: [:user, comments: [:author]]` | Whitelist loadable fields |
|
||||
| **Denied Loads** | `denied_loads: [:user]` | Blacklist loadable fields |
|
||||
| **Field Mapping** | `field_names [field_1: "field1"]` | Map invalid field names |
|
||||
| **Arg Mapping** | `argument_names [action: [arg_1: "arg1"]]` | Map invalid arg names |
|
||||
| **Type Mapping** | `def typescript_field_names, do: [...]` | NewType/TypedStruct callback |
|
||||
| **Metadata Config** | `show_metadata: [:field1]` | Control metadata exposure |
|
||||
| **Metadata Mapping** | `metadata_field_names: [field_1: "field1"]` | Map metadata names |
|
||||
| **Metadata (Read)** | `metadataFields: ["field1"]` | Merged into records |
|
||||
| **Metadata (Mutation)** | `result.metadata.field1` | Separate metadata field |
|
||||
| **Domain Namespace** | `typescript_rpc do namespace :api` | Default for all resources |
|
||||
| **Resource Namespace** | `resource X do namespace :todos` | Override domain default |
|
||||
| **Action Namespace** | `namespace: :custom` | Override resource default |
|
||||
| **Deprecation** | `deprecated: true` or `"message"` | Mark action deprecated |
|
||||
| **Related Actions** | `see: [:create_todo]` | Link in JSDoc |
|
||||
| **Description** | `description: "Custom desc"` | Override JSDoc description |
|
||||
| **Channel Function** | `actionNameChannel({channel, resultHandler})` | Phoenix channel RPC |
|
||||
| **Validation Fn** | `validateActionName({...})` | Client-side validation |
|
||||
| **Type Overrides** | `type_mapping_overrides: [{Module, "TSType"}]` | Map dependency types |
|
||||
| **Typed Controller** | `use AshTypescript.TypedController` | Controller-style routes |
|
||||
| **Controller Module** | `typed_controller do module_name MyWeb.Ctrl` | Generated controller module |
|
||||
| **Verb Shortcut** | `get :auth do run fn ... end end` | Preferred route syntax |
|
||||
| **Positional Method** | `route :login, :post do run fn ... end end` | Method as 2nd arg |
|
||||
| **Default GET** | `route :home do run fn ... end end` | Method defaults to :get |
|
||||
| **Route Argument** | `argument :code, :string, allow_nil?: false` | Colocated in route |
|
||||
| **Route Namespace** | `namespace "auth"` | Inside typed_controller or route do block |
|
||||
| **Route Description** | `description "..."` | JSDoc on route (inside do block) |
|
||||
| **Route Deprecated** | `deprecated true` | Deprecation notice (inside do block) |
|
||||
| **Route @see Tags** | `see [:auth, :logout]` | JSDoc `@see` cross-references |
|
||||
| **Typed Controllers** | `config :ash_typescript, typed_controllers: [M]` | Module discovery |
|
||||
| **Router Config** | `config :ash_typescript, router: MyWeb.Router` | Path introspection |
|
||||
| **Routes Output** | `config :ash_typescript, routes_output_file: "routes.ts"` | Route file path |
|
||||
| **Paths-Only Mode** | `config :ash_typescript, typed_controller_mode: :paths_only` | Skip fetch functions |
|
||||
| **GET Query Params** | `argument :q, :string, allow_nil?: false` on GET route | Becomes `?q=value` |
|
||||
| **Typed Channel** | `use AshTypescript.TypedChannel` | Server-push event subscriptions |
|
||||
| **Channel Topic** | `typed_channel do topic "org:*"` | Wildcard or static topic |
|
||||
| **Channel Resource** | `resource MyApp.Post do publish :event end` | Declare events per resource |
|
||||
| **Channel Create** | `createOrgChannel(socket, suffix)` | Factory with branded type |
|
||||
| **Channel Subscribe** | `onOrgChannelMessages(channel, handlers)` | Multi-event subscription |
|
||||
| **Channel Unsubscribe** | `unsubscribeOrgChannel(channel, refs)` | Cleanup all refs |
|
||||
| **Typed Channels** | `config :ash_typescript, typed_channels: [M]` | Module discovery |
|
||||
| **Channels Output** | `config :ash_typescript, typed_channels_output_file: "..."` | Channel functions file |
|
||||
| **JSON Manifest** | `config :ash_typescript, json_manifest_file: "manifest.json"` | Machine-readable action metadata |
|
||||
| **Manifest Filename** | `json_manifest_filename_format: :relative` | `:relative`, `:absolute`, or `:basename` |
|
||||
|
||||
## Action Feature Matrix
|
||||
|
||||
| Action Type | Fields | Filter | Page | Sort | Input | Identity |
|
||||
|-------------|--------|--------|------|------|-------|----------|
|
||||
| **read** | ✓ | ✓* | ✓ | ✓* | ✓ | - |
|
||||
| **read (get?/get_by)** | ✓ | - | - | - | ✓ | - |
|
||||
| **create** | ✓ | - | - | - | ✓ | - |
|
||||
| **update** | ✓ | - | - | - | ✓ | ✓ |
|
||||
| **destroy** | - | - | - | - | ✓ | ✓ |
|
||||
|
||||
*Can be disabled with `enable_filter?: false` / `enable_sort?: false`
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Domain do
|
||||
use Ash.Domain, extensions: [AshTypescript.Rpc]
|
||||
|
||||
typescript_rpc do
|
||||
resource MyApp.Todo do
|
||||
rpc_action :list_todos, :read
|
||||
rpc_action :create_todo, :create
|
||||
rpc_action :update_todo, :update
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
```typescript
|
||||
// Read with all features
|
||||
const todos = await listTodos({
|
||||
fields: ["id", "title", { user: ["name"] }],
|
||||
filter: { completed: { eq: false } },
|
||||
page: { limit: 10 },
|
||||
sort: "-createdAt",
|
||||
headers: buildCSRFHeaders()
|
||||
});
|
||||
|
||||
// Update requires identity
|
||||
await updateTodo({
|
||||
identity: "todo-123",
|
||||
input: { title: "Updated" },
|
||||
fields: ["id", "title"]
|
||||
});
|
||||
|
||||
// Phoenix channel
|
||||
createTodoChannel({
|
||||
channel: myChannel,
|
||||
input: { title: "New" },
|
||||
fields: ["id"],
|
||||
resultHandler: (r) => console.log(r.data)
|
||||
});
|
||||
```
|
||||
|
||||
### Field Name Mapping (Invalid Names)
|
||||
|
||||
```elixir
|
||||
# Resource attributes/calculations
|
||||
typescript do
|
||||
field_names [field_1: "field1", is_active?: "isActive"]
|
||||
argument_names [search: [filter_1: "filter1"]]
|
||||
end
|
||||
|
||||
# Custom types (NewType, TypedStruct, map constraints)
|
||||
def typescript_field_names, do: [field_1: "field1"]
|
||||
|
||||
# Metadata fields
|
||||
rpc_action :read, :read_with_meta,
|
||||
metadata_field_names: [meta_1: "meta1"]
|
||||
```
|
||||
|
||||
## Typed Controller (Route Helpers)
|
||||
|
||||
### When to Use
|
||||
|
||||
| Use Case | Extension |
|
||||
|----------|-----------|
|
||||
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
|
||||
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
|
||||
|
||||
### Setup
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Session do
|
||||
use AshTypescript.TypedController
|
||||
|
||||
typed_controller do
|
||||
module_name MyAppWeb.SessionController
|
||||
|
||||
# Verb shortcut (preferred)
|
||||
get :auth do
|
||||
run fn conn, _params -> render_inertia(conn, "Auth") end
|
||||
end
|
||||
|
||||
# Verb shortcut with args
|
||||
post :login do
|
||||
see [:auth, :logout]
|
||||
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
|
||||
argument :code, :string, allow_nil?: false
|
||||
argument :remember_me, :boolean
|
||||
end
|
||||
|
||||
# Positional method arg
|
||||
route :logout, :post do
|
||||
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
|
||||
end
|
||||
|
||||
# Default method (GET when omitted)
|
||||
route :home do
|
||||
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "Home") end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Generated TypeScript
|
||||
|
||||
```typescript
|
||||
// GET → path helper
|
||||
authPath() // → "/auth"
|
||||
|
||||
// GET with query args → path with query params
|
||||
searchPath({ q: "test", page: 1 }) // → "/search?q=test&page=1"
|
||||
|
||||
// POST → typed async function (via executeTypedControllerRequest helper)
|
||||
login({ code: "abc" }, { headers: { "X-CSRF-Token": token } })
|
||||
|
||||
// PATCH with path params + input
|
||||
updateProvider({ provider: "github" }, { enabled: true })
|
||||
```
|
||||
|
||||
**Function parameter order**: `path` (if path params) → `input` (if args) → `config?: TypedControllerConfig`
|
||||
|
||||
**Modes**: `:full` generates path helpers + fetch functions (+ Zod schemas if enabled). `:paths_only` generates only path helpers.
|
||||
|
||||
### Typed Controller Constraints
|
||||
|
||||
- Handlers must return `%Plug.Conn{}` directly — no `{:ok, conn}` wrapping
|
||||
- Multi-mount requires unique `as:` options on scopes for disambiguation
|
||||
- Not an Ash resource — standalone Spark DSL with colocated arguments
|
||||
- Path param `allow_nil?` must match presence: always present → `false`, sometimes present (multi-mount) → `true`
|
||||
|
||||
## Typed Channel (Event Subscriptions)
|
||||
|
||||
### When to Use
|
||||
|
||||
| Use Case | Extension |
|
||||
|----------|-----------|
|
||||
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
|
||||
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
|
||||
| Server pushes events to clients (notifications, updates) | `AshTypescript.TypedChannel` |
|
||||
|
||||
### Setup
|
||||
|
||||
```elixir
|
||||
defmodule MyAppWeb.OrgChannel do
|
||||
use AshTypescript.TypedChannel
|
||||
use Phoenix.Channel
|
||||
|
||||
typed_channel do
|
||||
topic "org:*"
|
||||
|
||||
resource MyApp.Post do
|
||||
publish :post_created
|
||||
publish :post_updated
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def join("org:" <> org_id, _payload, socket), do: {:ok, socket}
|
||||
end
|
||||
```
|
||||
|
||||
Resources must have `pub_sub` publications with matching `event:` names. Add `returns:` to publications for typed payloads (otherwise `unknown`).
|
||||
|
||||
### Generated TypeScript
|
||||
|
||||
```typescript
|
||||
// Create branded channel + subscribe
|
||||
const channel = createOrgChannel(socket, orgId);
|
||||
channel.join();
|
||||
|
||||
const refs = onOrgChannelMessages(channel, {
|
||||
post_created: (payload) => console.log(payload), // typed payload
|
||||
post_updated: (payload) => updatePost(payload),
|
||||
});
|
||||
|
||||
// Single event: onOrgChannelMessage(channel, "post_created", handler)
|
||||
|
||||
// Cleanup
|
||||
unsubscribeOrgChannel(channel, refs);
|
||||
```
|
||||
|
||||
### Topic Patterns
|
||||
|
||||
| Topic Pattern | Factory Signature |
|
||||
|--------------|-------------------|
|
||||
| `"org:*"` (wildcard) | `createOrgChannel(socket, suffix)` |
|
||||
| `"global"` (no wildcard) | `createGlobalChannel(socket)` |
|
||||
|
||||
### Typed Channel Constraints
|
||||
|
||||
- Event names must be unique across all resources in a channel
|
||||
- Publications need `public?: true` (warning if missing)
|
||||
- Publications need `returns:` option for typed payloads (warning if missing, falls back to `unknown`)
|
||||
- Channel types go in `ash_types.ts`; channel functions go in `typed_channels_output_file`
|
||||
|
||||
## JSON Manifest (Third-Party Integrations)
|
||||
|
||||
When `json_manifest_file` is configured, `mix ash_typescript.codegen` generates a machine-readable JSON manifest. This enables third-party packages (e.g., TanStack Query wrappers) to introspect the generated API without coupling to ash_typescript internals.
|
||||
|
||||
```elixir
|
||||
config :ash_typescript,
|
||||
json_manifest_file: "assets/js/ash_rpc_manifest.json",
|
||||
json_manifest_filename_format: :relative # :relative | :absolute | :basename
|
||||
```
|
||||
|
||||
The manifest contains:
|
||||
- **`files`** — generated file locations with `importPath` (for TS imports, always relative, no `.ts`) and `filename` (format controlled by config)
|
||||
- **`actions`** — every RPC action with: `functionName`, `actionType` (read/create/update/destroy/action), `get`, `namespace`, `types` (result, fields, input, config, filterInput — only present when applicable), `pagination`, `enableFilter`, `enableSort`, `variants`/`variantNames`, `deprecated`, `see`, `input` (none/optional/required)
|
||||
- **`typedControllerRoutes`** — each route with: `functionName`, `method`, `path`, `pathParams`, `mutation`, `types`
|
||||
- **`version`** — semver string (currently `"1.0"`) for consumer compatibility
|
||||
|
||||
### Consumer Example
|
||||
|
||||
```typescript
|
||||
import manifest from "./ash_rpc_manifest.json";
|
||||
|
||||
for (const action of manifest.actions) {
|
||||
const isQuery = action.actionType === "read";
|
||||
// Import from manifest.files.rpc.importPath
|
||||
// Generate queryOptions/mutationOptions wrappers
|
||||
}
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
| Error Pattern | Fix |
|
||||
|---------------|-----|
|
||||
| Missing `extensions: [AshTypescript.Rpc]` | Add to domain |
|
||||
| Missing `typescript` block on resource | Add `AshTypescript.Resource` extension + `typescript do type_name "X" end` |
|
||||
| No `rpc_action` declarations | Explicitly declare each action |
|
||||
| Filter syntax `{ field: false }` | Use operators: `{ field: { eq: false } }` |
|
||||
| Missing `fields` parameter | Always include `fields: [...]` |
|
||||
| Get action error on not found | Add `not_found_error?: false` |
|
||||
| Invalid field name `field_1` or `is_active?` | Add field mapping |
|
||||
| Identity not found | Check `identities` config; use `{ field: value }` for named |
|
||||
| Load not allowed/denied | Check `allowed_loads`/`denied_loads` config |
|
||||
| Channel/validation fn undefined | Enable in config |
|
||||
| Typed controller 500 error | Handler must return `%Plug.Conn{}` |
|
||||
| Routes not generated | Set `typed_controllers:`, `router:`, and `routes_output_file:` in config |
|
||||
| Multi-mount ambiguity error | Add unique `as:` option to each scope |
|
||||
| Path param without matching argument | Add `argument :param, :string` to route |
|
||||
| Path param `allow_nil?` mismatch | Always-present → `false`; sometimes-present → `true` |
|
||||
| Route hooks not firing | Check `typed_controller_import_into_generated` + hook names |
|
||||
| Typed channel event not found | Event name must match `event:` option on resource's `pub_sub` publication |
|
||||
| Duplicate channel event names | Use unique event names across all resources in one channel |
|
||||
| Channel payload is `unknown` | Add `returns:` option to the resource's `pub_sub` publication |
|
||||
| Typed channels not generated | Set `typed_channels:` and `typed_channels_output_file:` in config |
|
||||
|
||||
## Error Quick Reference
|
||||
|
||||
| Error Contains | Fix |
|
||||
|----------------|-----|
|
||||
| "Property does not exist" | Run `mix ash_typescript.codegen` |
|
||||
| "fields is required" | Add `fields: [...]` |
|
||||
| "No domains found" | Use `MIX_ENV=test` for test resources |
|
||||
| "Action not found" | Add `rpc_action` declaration |
|
||||
| "403 Forbidden" | Use `buildCSRFHeaders()` |
|
||||
| "Invalid field names" | Add mapping (see Field Name Mapping) |
|
||||
| "load_not_allowed" / "load_denied" | Check load restrictions config |
|
||||
| "allow_nil?: true" + path param | Set `allow_nil?: false` for always-present path params |
|
||||
| "allow_nil?: false" + sometimes-present | Use `allow_nil?: true` for multi-mount path params |
|
||||
| "No publication with event X found" | Check `event:` option on resource's `pub_sub` block |
|
||||
| "Duplicate event names found" | Use unique event names per channel |
|
||||
|
||||
## Configuration
|
||||
|
||||
```elixir
|
||||
config :ash_typescript,
|
||||
output_file: "assets/js/ash_rpc.ts",
|
||||
run_endpoint: "/rpc/run",
|
||||
validate_endpoint: "/rpc/validate",
|
||||
generate_validation_functions: false,
|
||||
generate_phx_channel_rpc_actions: false,
|
||||
generate_zod_schemas: false,
|
||||
require_tenant_parameters: false,
|
||||
not_found_error?: true,
|
||||
# JSDoc/Manifest
|
||||
add_ash_internals_to_jsdoc: false,
|
||||
add_ash_internals_to_manifest: false,
|
||||
manifest_file: nil,
|
||||
json_manifest_file: nil, # Machine-readable JSON manifest for third-party tools
|
||||
json_manifest_filename_format: :relative, # :relative | :absolute | :basename
|
||||
source_path_prefix: nil, # For monorepos: "backend"
|
||||
# Warnings
|
||||
warn_on_missing_rpc_config: true,
|
||||
warn_on_non_rpc_references: true,
|
||||
# Dev codegen behavior
|
||||
always_regenerate: false,
|
||||
# Imports/Types
|
||||
import_into_generated: [%{import_name: "CustomTypes", file: "./customTypes"}],
|
||||
type_mapping_overrides: [{MyApp.CustomType, "string"}],
|
||||
# Typed Controller (route helpers)
|
||||
typed_controllers: [MyApp.Session],
|
||||
router: MyAppWeb.Router,
|
||||
routes_output_file: "assets/js/routes.ts",
|
||||
typed_controller_mode: :full, # :full or :paths_only
|
||||
typed_controller_path_params_style: :object, # :object or :args
|
||||
# Optional: lifecycle hooks, custom imports, error handling
|
||||
# typed_controller_before_request_hook: "RouteHooks.beforeRequest",
|
||||
# typed_controller_after_request_hook: "RouteHooks.afterRequest",
|
||||
# typed_controller_hook_context_type: "RouteHooks.RouteHookContext",
|
||||
# typed_controller_import_into_generated: [%{import_name: "RouteHooks", file: "./routeHooks"}],
|
||||
# typed_controller_error_handler: {MyApp.ErrorHandler, :handle, []},
|
||||
# typed_controller_show_raised_errors: false # true only in dev
|
||||
# Typed Channel (event subscriptions)
|
||||
typed_channels: [MyApp.OrgChannel],
|
||||
typed_channels_output_file: "assets/js/ash_typed_channels.ts"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
mix ash_typescript.codegen # Generate
|
||||
mix ash_typescript.codegen --check # Verify up-to-date (CI)
|
||||
mix ash_typescript.codegen --dry-run # Preview
|
||||
npx tsc ash_rpc.ts --noEmit # Validate TS
|
||||
```
|
||||
180
.claude/skills/ash-framework/references/authorization.md
Normal file
180
.claude/skills/ash-framework/references/authorization.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Authorization
|
||||
|
||||
- When performing administrative actions, you can bypass authorization with `authorize?: false`
|
||||
- To run actions as a particular user, look that user up and pass it as the `actor` option
|
||||
- Always set the actor on the query/changeset/input, not when calling the action
|
||||
- Use policies to define authorization rules
|
||||
|
||||
```elixir
|
||||
# Good
|
||||
Post
|
||||
|> Ash.Query.for_read(:read, %{}, actor: current_user)
|
||||
|> Ash.read!()
|
||||
|
||||
# BAD, DO NOT DO THIS
|
||||
Post
|
||||
|> Ash.Query.for_read(:read, %{})
|
||||
|> Ash.read!(actor: current_user)
|
||||
```
|
||||
|
||||
## Policies
|
||||
|
||||
To use policies, add the `Ash.Policy.Authorizer` to your resource:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Post do
|
||||
use Ash.Resource,
|
||||
domain: MyApp.Blog,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
# Rest of resource definition...
|
||||
end
|
||||
```
|
||||
|
||||
## Policy Basics
|
||||
|
||||
Policies determine what actions on a resource are permitted for a given actor. Define policies in the `policies` block:
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
# A simple policy that applies to all read actions
|
||||
policy action_type(:read) do
|
||||
# Authorize if record is public
|
||||
authorize_if expr(public == true)
|
||||
|
||||
# Authorize if actor is the owner
|
||||
authorize_if relates_to_actor_via(:owner)
|
||||
end
|
||||
|
||||
# A policy for create actions
|
||||
policy action_type(:create) do
|
||||
# Only allow active users to create records
|
||||
forbid_unless actor_attribute_equals(:active, true)
|
||||
|
||||
# Ensure the record being created relates to the actor
|
||||
authorize_if relating_to_actor(:owner)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Policy Evaluation Flow
|
||||
|
||||
Policies evaluate from top to bottom with the following logic:
|
||||
|
||||
1. All policies that apply to an action must pass for the action to be allowed
|
||||
2. Within each policy, checks evaluate from top to bottom
|
||||
3. The first check that produces a decision determines the policy result
|
||||
4. If no check produces a decision, the policy defaults to forbidden
|
||||
|
||||
## IMPORTANT: Policy Check Logic
|
||||
|
||||
**the first check that yields a result determines the policy outcome**
|
||||
|
||||
```elixir
|
||||
# WRONG - This is OR logic, not AND logic!
|
||||
policy action_type(:update) do
|
||||
authorize_if actor_attribute_equals(:admin?, true) # If this passes, policy passes
|
||||
authorize_if relates_to_actor_via(:owner) # Only checked if first fails
|
||||
end
|
||||
```
|
||||
|
||||
To require BOTH conditions in that example, you would use `forbid_unless` for the first condition:
|
||||
|
||||
```elixir
|
||||
# CORRECT - This requires BOTH conditions
|
||||
policy action_type(:update) do
|
||||
forbid_unless actor_attribute_equals(:admin?, true) # Must be admin
|
||||
authorize_if relates_to_actor_via(:owner) # AND must be owner
|
||||
end
|
||||
```
|
||||
|
||||
Alternative patterns for AND logic:
|
||||
- Use multiple separate policies (each must pass independently)
|
||||
- Use a single complex expression with `expr(condition1 and condition2)`
|
||||
- Use `forbid_unless` for required conditions, then `authorize_if` for the final check
|
||||
|
||||
## Bypass Policies
|
||||
|
||||
Use bypass policies to allow certain actors to bypass other policy restrictions. This should be used almost exclusively for admin bypasses.
|
||||
|
||||
```elixir
|
||||
policies do
|
||||
# Bypass policy for admins - if this passes, other policies don't need to pass
|
||||
bypass actor_attribute_equals(:admin, true) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
# Regular policies follow...
|
||||
policy action_type(:read) do
|
||||
# ...
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Field Policies
|
||||
|
||||
Field policies control access to specific fields (attributes, calculations, aggregates):
|
||||
|
||||
```elixir
|
||||
field_policies do
|
||||
# Only supervisors can see the salary field
|
||||
field_policy :salary do
|
||||
authorize_if actor_attribute_equals(:role, :supervisor)
|
||||
end
|
||||
|
||||
# Allow access to all other fields
|
||||
field_policy :* do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Policy Checks
|
||||
|
||||
There are two main types of checks used in policies:
|
||||
|
||||
1. **Simple checks** - Return true/false answers (e.g., "is the actor an admin?")
|
||||
2. **Filter checks** - Return filters to apply to data (e.g., "only show records owned by the actor")
|
||||
|
||||
You can use built-in checks or create custom ones:
|
||||
|
||||
```elixir
|
||||
# Built-in checks
|
||||
authorize_if actor_attribute_equals(:role, :admin)
|
||||
authorize_if relates_to_actor_via(:owner)
|
||||
authorize_if expr(public == true)
|
||||
|
||||
# Custom check module
|
||||
authorize_if MyApp.Checks.ActorHasPermission
|
||||
```
|
||||
|
||||
### Custom Policy Checks
|
||||
|
||||
Create custom checks by implementing `Ash.Policy.SimpleCheck` or `Ash.Policy.FilterCheck`:
|
||||
|
||||
```elixir
|
||||
# Simple check - returns true/false
|
||||
defmodule MyApp.Checks.ActorHasRole do
|
||||
use Ash.Policy.SimpleCheck
|
||||
|
||||
def match?(%{role: actor_role}, _context, opts) do
|
||||
actor_role == (opts[:role] || :admin)
|
||||
end
|
||||
def match?(_, _, _), do: false
|
||||
end
|
||||
|
||||
# Filter check - returns query filter
|
||||
defmodule MyApp.Checks.VisibleToUserLevel do
|
||||
use Ash.Policy.FilterCheck
|
||||
|
||||
def filter(actor, _authorizer, _opts) do
|
||||
expr(visibility_level <= ^actor.user_level)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
policy action_type(:read) do
|
||||
authorize_if {MyApp.Checks.ActorHasRole, role: :manager}
|
||||
authorize_if MyApp.Checks.VisibleToUserLevel
|
||||
end
|
||||
```
|
||||
149
.claude/skills/ash-framework/references/calculations.md
Normal file
149
.claude/skills/ash-framework/references/calculations.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Calculations
|
||||
|
||||
Calculations allow you to define derived values based on a resource's attributes or related data. Define calculations in the `calculations` block of a resource:
|
||||
|
||||
```elixir
|
||||
calculations do
|
||||
# Simple expression calculation
|
||||
calculate :full_name, :string, expr(first_name <> " " <> last_name)
|
||||
|
||||
# Expression with conditions
|
||||
calculate :status_label, :string, expr(
|
||||
cond do
|
||||
status == :active -> "Active"
|
||||
status == :pending -> "Pending Review"
|
||||
true -> "Inactive"
|
||||
end
|
||||
)
|
||||
|
||||
# Using module calculations for more complex logic
|
||||
calculate :risk_score, :integer, {MyApp.Calculations.RiskScore, min: 0, max: 100}
|
||||
end
|
||||
```
|
||||
|
||||
## Expression Calculations
|
||||
|
||||
Expression calculations use Ash expressions and can be pushed down to the data layer when possible:
|
||||
|
||||
```elixir
|
||||
calculations do
|
||||
# Simple string concatenation
|
||||
calculate :full_name, :string, expr(first_name <> " " <> last_name)
|
||||
|
||||
# Math operations
|
||||
calculate :total_with_tax, :decimal, expr(amount * (1 + tax_rate))
|
||||
|
||||
# Date manipulation
|
||||
calculate :days_since_created, :integer, expr(
|
||||
date_diff(^now(), inserted_at, :day)
|
||||
)
|
||||
end
|
||||
```
|
||||
|
||||
## Expressions
|
||||
|
||||
In order to use expressions outside of resources, changes, preparations etc. you will need to use `Ash.Expr`.
|
||||
|
||||
It provides both `expr/1` and template helpers like `actor/1` and `arg/1`.
|
||||
|
||||
For example:
|
||||
|
||||
```elixir
|
||||
import Ash.Expr
|
||||
|
||||
Author
|
||||
|> Ash.Query.aggregate(:count_of_my_favorited_posts, :count, [:posts], query: [
|
||||
filter: expr(favorited_by(user_id: ^actor(:id)))
|
||||
])
|
||||
```
|
||||
|
||||
See the expressions guide for more information on what is available in expresisons and
|
||||
how to use them.
|
||||
|
||||
## Module Calculations
|
||||
|
||||
For complex calculations, create a module that implements `Ash.Resource.Calculation`:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Calculations.FullName do
|
||||
use Ash.Resource.Calculation
|
||||
|
||||
# Validate and transform options
|
||||
@impl true
|
||||
def init(opts) do
|
||||
{:ok, Map.put_new(opts, :separator, " ")}
|
||||
end
|
||||
|
||||
# Specify what data needs to be loaded
|
||||
@impl true
|
||||
def load(_query, _opts, _context) do
|
||||
[:first_name, :last_name]
|
||||
end
|
||||
|
||||
# Implement the calculation logic
|
||||
@impl true
|
||||
def calculate(records, opts, _context) do
|
||||
Enum.map(records, fn record ->
|
||||
[record.first_name, record.last_name]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.join(opts.separator)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in a resource
|
||||
calculations do
|
||||
calculate :full_name, :string, {MyApp.Calculations.FullName, separator: ", "}
|
||||
end
|
||||
```
|
||||
|
||||
## Calculations with Arguments
|
||||
|
||||
You can define calculations that accept arguments:
|
||||
|
||||
```elixir
|
||||
calculations do
|
||||
calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do
|
||||
argument :separator, :string do
|
||||
allow_nil? false
|
||||
default " "
|
||||
constraints [allow_empty?: true, trim?: false]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Using Calculations
|
||||
|
||||
```elixir
|
||||
# Using code interface options (preferred)
|
||||
users = MyDomain.list_users!(load: [full_name: [separator: ", "]])
|
||||
|
||||
# Filtering and sorting
|
||||
users = MyDomain.list_users!(
|
||||
query: [
|
||||
filter: [full_name: [separator: " ", value: "John Doe"]],
|
||||
sort: [full_name: {[separator: " "], :asc}]
|
||||
]
|
||||
)
|
||||
|
||||
# Manual query building (for complex cases)
|
||||
User |> Ash.Query.load(full_name: [separator: ", "]) |> Ash.read!()
|
||||
|
||||
# Loading on existing records
|
||||
Ash.load!(users, :full_name)
|
||||
```
|
||||
|
||||
### Code Interface for Calculations
|
||||
|
||||
Define calculation functions on your domain for standalone use:
|
||||
|
||||
```elixir
|
||||
# In your domain
|
||||
resource User do
|
||||
define_calculation :full_name, args: [:first_name, :last_name, {:optional, :separator}]
|
||||
end
|
||||
|
||||
# Then call it directly
|
||||
MyDomain.full_name("John", "Doe", ", ") # Returns "John, Doe"
|
||||
```
|
||||
134
.claude/skills/ash-framework/references/code_interfaces.md
Normal file
134
.claude/skills/ash-framework/references/code_interfaces.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Code Interfaces
|
||||
|
||||
Use code interfaces on domains to define the contract for calling into Ash resources. See the [Code interface guide for more](https://hexdocs.pm/ash/code-interfaces.html).
|
||||
|
||||
Define code interfaces on the domain, like this:
|
||||
|
||||
```elixir
|
||||
resource ResourceName do
|
||||
define :fun_name, action: :action_name
|
||||
end
|
||||
```
|
||||
|
||||
For more complex interfaces with custom transformations:
|
||||
|
||||
```elixir
|
||||
define :custom_action do
|
||||
action :action_name
|
||||
args [:arg1, :arg2]
|
||||
|
||||
custom_input :arg1, MyType do
|
||||
transform do
|
||||
to :target_field
|
||||
using &MyModule.transform_function/1
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Prefer using the primary read action for "get" style code interfaces, and using `get_by` when the field you are looking up by is the primary key or has an `identity` on the resource.
|
||||
|
||||
```elixir
|
||||
resource ResourceName do
|
||||
define :get_thing, action: :read, get_by: [:id]
|
||||
end
|
||||
```
|
||||
|
||||
**Avoid direct Ash calls in web modules** - Don't use `Ash.get!/2` and `Ash.load!/2` directly in LiveViews/Controllers, similar to avoiding `Repo.get/2` outside context modules:
|
||||
|
||||
You can also pass additional inputs in to code interfaces before the options:
|
||||
|
||||
```elixir
|
||||
resource ResourceName do
|
||||
define :create, action: :action_name, args: [:field1]
|
||||
end
|
||||
```
|
||||
|
||||
```elixir
|
||||
Domain.create!(field1_value, %{field2: field2_value}, actor: current_user)
|
||||
```
|
||||
|
||||
You should generally prefer using this map of extra inputs over defining optional arguments.
|
||||
|
||||
```elixir
|
||||
# BAD - in LiveView/Controller
|
||||
group = MyApp.Resource |> Ash.get!(id) |> Ash.load!(rel: [:nested])
|
||||
|
||||
# GOOD - use code interface with get_by
|
||||
resource DashboardGroup do
|
||||
define :get_dashboard_group_by_id, action: :read, get_by: [:id]
|
||||
end
|
||||
|
||||
# Then call:
|
||||
MyApp.Domain.get_dashboard_group_by_id!(id, load: [rel: [:nested]])
|
||||
```
|
||||
|
||||
**Code interface options** - Prefer passing options directly to code interface functions rather than building queries manually:
|
||||
|
||||
```elixir
|
||||
# PREFERRED - Use the query option for filter, sort, limit, etc.
|
||||
# the query option is passed to `Ash.Query.build/2`
|
||||
posts = MyApp.Blog.list_posts!(
|
||||
query: [
|
||||
filter: [status: :published],
|
||||
sort: [published_at: :desc],
|
||||
limit: 10
|
||||
],
|
||||
load: [author: :profile, comments: [:author]]
|
||||
)
|
||||
|
||||
# All query-related options go in the query parameter
|
||||
users = MyApp.Accounts.list_users!(
|
||||
query: [filter: [active: true], sort: [created_at: :desc]],
|
||||
load: [:profile]
|
||||
)
|
||||
|
||||
# AVOID - Verbose manual query building
|
||||
query = MyApp.Post |> Ash.Query.filter(...) |> Ash.Query.load(...)
|
||||
posts = Ash.read!(query)
|
||||
```
|
||||
|
||||
Supported options: `load:`, `query:` (which accepts `filter:`, `sort:`, `limit:`, `offset:`, etc.), `page:`, `stream?:`
|
||||
|
||||
**Using Scopes in LiveViews** - When using `Ash.Scope`, the scope will typically be assigned to `scope` in LiveViews and used like so:
|
||||
|
||||
```elixir
|
||||
# In your LiveView
|
||||
MyApp.Blog.create_post!("new post", scope: socket.assigns.scope)
|
||||
```
|
||||
|
||||
Inside action hooks and callbacks, use the provided `context` parameter as your scope instead:
|
||||
|
||||
```elixir
|
||||
|> Ash.Changeset.before_transaction(fn changeset, context ->
|
||||
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
|
||||
changeset
|
||||
end)
|
||||
```
|
||||
|
||||
## Authorization Functions
|
||||
|
||||
For each action defined in a code interface, Ash automatically generates corresponding authorization check functions:
|
||||
|
||||
- `can_action_name?(actor, params \\ %{}, opts \\ [])` - Returns `true`/`false` for authorization checks
|
||||
- `can_action_name(actor, params \\ %{}, opts \\ [])` - Returns `{:ok, true/false}` or `{:error, reason}`
|
||||
|
||||
Example usage:
|
||||
```elixir
|
||||
# Check if user can create a post
|
||||
if MyApp.Blog.can_create_post?(current_user) do
|
||||
# Show create button
|
||||
end
|
||||
|
||||
# Check if user can update a specific post
|
||||
if MyApp.Blog.can_update_post?(current_user, post) do
|
||||
# Show edit button
|
||||
end
|
||||
|
||||
# Check if user can destroy a specific comment
|
||||
if MyApp.Blog.can_destroy_comment?(current_user, comment) do
|
||||
# Show delete button
|
||||
end
|
||||
```
|
||||
|
||||
These functions are particularly useful for conditional rendering of UI elements based on user permissions.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Code Structure & Organization
|
||||
|
||||
- Organize code around domains and resources
|
||||
- Each resource should be focused and well-named
|
||||
- Create domain-specific actions rather than generic CRUD operations
|
||||
- Put business logic inside actions rather than in external modules
|
||||
- Use resources to model your domain entities
|
||||
44
.claude/skills/ash-framework/references/data_layers.md
Normal file
44
.claude/skills/ash-framework/references/data_layers.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Data Layers
|
||||
|
||||
Data layers determine how resources are stored and retrieved. Examples of data layers:
|
||||
|
||||
- **Postgres**: For storing resources in PostgreSQL (via `AshPostgres`)
|
||||
- **ETS**: For in-memory storage (`Ash.DataLayer.Ets`)
|
||||
- **Mnesia**: For distributed storage (`Ash.DataLayer.Mnesia`)
|
||||
- **Embedded**: For resources embedded in other resources (`data_layer: :embedded`) (typically JSON under the hood)
|
||||
- **Ash.DataLayer.Simple**: For resources that aren't persisted at all. Leave off the data layer, as this is the default.
|
||||
|
||||
Specify a data layer when defining a resource:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Post do
|
||||
use Ash.Resource,
|
||||
domain: MyApp.Blog,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "posts"
|
||||
repo MyApp.Repo
|
||||
end
|
||||
|
||||
# ... attributes, relationships, etc.
|
||||
end
|
||||
```
|
||||
|
||||
For embedded resources:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Address do
|
||||
use Ash.Resource,
|
||||
data_layer: :embedded
|
||||
|
||||
attributes do
|
||||
attribute :street, :string
|
||||
attribute :city, :string
|
||||
attribute :state, :string
|
||||
attribute :zip, :string
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Each data layer has its own configuration options and capabilities. Refer to the rules & documentation of the specific data layer package for more details.
|
||||
31
.claude/skills/ash-framework/references/exist_expressions.md
Normal file
31
.claude/skills/ash-framework/references/exist_expressions.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Exists Expressions
|
||||
|
||||
Use `exists/2` to check for the existence of records, either through relationships or unrelated resources:
|
||||
|
||||
### Related Exists
|
||||
|
||||
```elixir
|
||||
# Check if user has any admin roles
|
||||
Ash.Query.filter(User, exists(roles, name == "admin"))
|
||||
|
||||
# Check if post has comments with high scores
|
||||
Ash.Query.filter(Post, exists(comments, score > 50))
|
||||
```
|
||||
|
||||
### Unrelated Exists
|
||||
|
||||
```elixir
|
||||
# Check if any profile exists with the same name
|
||||
Ash.Query.filter(User, exists(Profile, name == parent(name)))
|
||||
|
||||
# Check if user has any reports
|
||||
Ash.Query.filter(User, exists(Report, author_name == parent(name)))
|
||||
|
||||
# Complex existence checks
|
||||
Ash.Query.filter(User,
|
||||
active == true and
|
||||
exists(Profile, active == true and name == parent(name))
|
||||
)
|
||||
```
|
||||
|
||||
Unrelated exists expressions automatically apply authorization using the target resource's primary read action. Use `parent/1` to reference fields from the source resource.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generating Code
|
||||
|
||||
Use `mix ash.gen.*` tasks as a basis for code generation when possible. Check the task docs with `mix help <task>`.
|
||||
Be sure to use `--yes` to bypass confirmation prompts. Use `--yes --dry-run` to preview the changes.
|
||||
3
.claude/skills/ash-framework/references/migrations.md
Normal file
3
.claude/skills/ash-framework/references/migrations.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Migrations and Schema Changes
|
||||
|
||||
After creating or modifying Ash code, run `mix ash.codegen <short_name_describing_changes>` to ensure any required additional changes are made (like migrations are generated). The name of the migration should be lower_snake_case. In a longer running dev session it's usually better to use `mix ash.codegen --dev` as you go and at the end run the final codegen with a sensible name describing all the changes made in the session.
|
||||
28
.claude/skills/ash-framework/references/query_filter.md
Normal file
28
.claude/skills/ash-framework/references/query_filter.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Ash.Query.filter is a macro
|
||||
|
||||
**Important**: You must `require Ash.Query` if you want to use `Ash.Query.filter/2`, as it is a macro.
|
||||
|
||||
If you see errors like the following:
|
||||
|
||||
```
|
||||
Ash.Query.filter(MyResource, id == ^id)
|
||||
error: misplaced operator ^id
|
||||
|
||||
The pin operator ^ is supported only inside matches or inside custom macros...
|
||||
```
|
||||
|
||||
```
|
||||
iex(3)> Ash.Query.filter(MyResource, something == true)
|
||||
error: undefined variable "something"
|
||||
└─ iex:3
|
||||
```
|
||||
|
||||
You are very likely missing a `require Ash.Query`
|
||||
|
||||
## Common Query Operations
|
||||
|
||||
- **Filter**: `Ash.Query.filter(query, field == value)`
|
||||
- **Sort**: `Ash.Query.sort(query, field: :asc)`
|
||||
- **Load relationships**: `Ash.Query.load(query, [:author, :comments])`
|
||||
- **Limit**: `Ash.Query.limit(query, 10)`
|
||||
- **Offset**: `Ash.Query.offset(query, 20)`
|
||||
3
.claude/skills/ash-framework/references/querying_data.md
Normal file
3
.claude/skills/ash-framework/references/querying_data.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Querying Data
|
||||
|
||||
Use `Ash.Query` to build queries for reading data from your resources. The query module provides a declarative way to filter, sort, and load data.
|
||||
170
.claude/skills/ash-framework/references/relationships.md
Normal file
170
.claude/skills/ash-framework/references/relationships.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Relationships
|
||||
|
||||
Relationships describe connections between resources and are a core component of Ash. Define relationships in the `relationships` block of a resource.
|
||||
|
||||
## Best Practices for Relationships
|
||||
|
||||
- Be descriptive with relationship names (e.g., use `:authored_posts` instead of just `:posts`)
|
||||
- Configure foreign key constraints in your data layer if they have them (see `references` in AshPostgres)
|
||||
- Always choose the appropriate relationship type based on your domain model
|
||||
|
||||
### Relationship Types
|
||||
|
||||
- For Polymorphic relationships, you can model them using `Ash.Type.Union`; see the “Polymorphic Relationships” guide for more information.
|
||||
|
||||
```elixir
|
||||
relationships do
|
||||
# belongs_to - adds foreign key to source resource
|
||||
belongs_to :owner, MyApp.User do
|
||||
allow_nil? false
|
||||
attribute_type :integer # defaults to :uuid
|
||||
end
|
||||
|
||||
# has_one - foreign key on destination resource
|
||||
has_one :profile, MyApp.Profile
|
||||
|
||||
# has_many - foreign key on destination resource, returns list
|
||||
has_many :posts, MyApp.Post do
|
||||
filter expr(published == true)
|
||||
sort published_at: :desc
|
||||
end
|
||||
|
||||
# many_to_many - requires join resource
|
||||
many_to_many :tags, MyApp.Tag do
|
||||
through MyApp.PostTag
|
||||
source_attribute_on_join_resource :post_id
|
||||
destination_attribute_on_join_resource :tag_id
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The join resource must be defined separately:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.PostTag do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
# Add additional attributes if you need metadata on the relationship
|
||||
attribute :added_at, :utc_datetime_usec do
|
||||
default &DateTime.utc_now/0
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :post, MyApp.Post, primary_key?: true, allow_nil?: false
|
||||
belongs_to :tag, MyApp.Tag, primary_key?: true, allow_nil?: false
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy, create: :*, update: :*]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Loading Relationships
|
||||
|
||||
```elixir
|
||||
# Using code interface options (preferred)
|
||||
post = MyDomain.get_post!(id, load: [:author, comments: [:author]])
|
||||
|
||||
# Complex loading with filters
|
||||
posts = MyDomain.list_posts!(
|
||||
query: [load: [comments: [filter: [is_approved: true], limit: 5]]]
|
||||
)
|
||||
|
||||
# Manual query building (for complex cases)
|
||||
MyApp.Post
|
||||
|> Ash.Query.load(comments: MyApp.Comment |> Ash.Query.filter(is_approved == true))
|
||||
|> Ash.read!()
|
||||
|
||||
# Loading on existing records
|
||||
Ash.load!(post, :author)
|
||||
```
|
||||
|
||||
Prefer to use the `strict?` option when loading to only load necessary fields on related data.
|
||||
|
||||
```elixir
|
||||
MyApp.Post
|
||||
|> Ash.Query.load([comments: [:title]], strict?: true)
|
||||
```
|
||||
|
||||
## Managing Relationships
|
||||
|
||||
There are two primary ways to manage relationships in Ash:
|
||||
|
||||
### 1. Using `change manage_relationship/2-3` in Actions
|
||||
Use this when input comes from action arguments:
|
||||
|
||||
```elixir
|
||||
actions do
|
||||
update :update do
|
||||
# Define argument for the related data
|
||||
argument :comments, {:array, :map} do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
argument :new_tags, {:array, :map}
|
||||
|
||||
# Link argument to relationship management
|
||||
change manage_relationship(:comments, type: :append)
|
||||
|
||||
# For different argument and relationship names
|
||||
change manage_relationship(:new_tags, :tags, type: :append)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2. Using `Ash.Changeset.manage_relationship/3-4` in Custom Changes
|
||||
Use this when building values programmatically:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Changes.AssignTeamMembers do
|
||||
use Ash.Resource.Change
|
||||
|
||||
def change(changeset, _opts, context) do
|
||||
members = determine_team_members(changeset, context.actor)
|
||||
|
||||
Ash.Changeset.manage_relationship(
|
||||
changeset,
|
||||
:members,
|
||||
members,
|
||||
type: :append_and_remove
|
||||
)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Quick Reference - Management Types
|
||||
- `:append` - Add new related records, ignore existing
|
||||
- `:append_and_remove` - Add new related records, remove missing
|
||||
- `:remove` - Remove specified related records
|
||||
- `:direct_control` - Full CRUD control (create/update/destroy)
|
||||
- `:create` - Only create new records
|
||||
|
||||
### Quick Reference - Common Options
|
||||
- `on_lookup: :relate` - Look up and relate existing records
|
||||
- `on_no_match: :create` - Create if no match found
|
||||
- `on_match: :update` - Update existing matches
|
||||
- `on_missing: :destroy` - Delete records not in input
|
||||
- `value_is_key: :name` - Use field as key for simple values
|
||||
|
||||
For comprehensive documentation, see the [Managing Relationships](https://hexdocs.pm/ash/relationships.html#managing-relationships) section.
|
||||
|
||||
### Examples
|
||||
|
||||
Creating a post with tags:
|
||||
```elixir
|
||||
MyDomain.create_post!(%{
|
||||
title: "New Post",
|
||||
body: "Content here...",
|
||||
tags: [%{name: "elixir"}, %{name: "ash"}] # Creates new tags
|
||||
})
|
||||
|
||||
# Updating a post to replace its tags
|
||||
MyDomain.update_post!(post, %{
|
||||
tags: [tag1.id, tag2.id] # Replaces tags with existing ones by ID
|
||||
})
|
||||
```
|
||||
59
.claude/skills/ash-framework/references/testing.md
Normal file
59
.claude/skills/ash-framework/references/testing.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Testing
|
||||
|
||||
When testing resources:
|
||||
- Test your domain actions through the code interface
|
||||
- Use test utilities in `Ash.Test`
|
||||
- Test authorization policies work as expected using `Ash.can?`
|
||||
- Use `authorize?: false` in tests where authorization is not the focus
|
||||
- Write generators using `Ash.Generator`
|
||||
- Prefer to use raising versions of functions whenever possible, as opposed to pattern matching
|
||||
|
||||
## Preventing Deadlocks in Concurrent Tests
|
||||
|
||||
When running tests concurrently, using fixed values for identity attributes can cause deadlock errors. Multiple tests attempting to create records with the same unique values will conflict.
|
||||
|
||||
### Use Globally Unique Values
|
||||
|
||||
Always use globally unique values for identity attributes in tests:
|
||||
|
||||
```elixir
|
||||
# BAD - Can cause deadlocks in concurrent tests
|
||||
%{email: "test@example.com", username: "testuser"}
|
||||
|
||||
# GOOD - Use globally unique values
|
||||
%{
|
||||
email: "test-#{System.unique_integer([:positive])}@example.com",
|
||||
username: "user_#{System.unique_integer([:positive])}",
|
||||
slug: "post-#{System.unique_integer([:positive])}"
|
||||
}
|
||||
```
|
||||
|
||||
### Creating Reusable Test Generators
|
||||
|
||||
For better organization, create a generator module:
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.TestGenerators do
|
||||
use Ash.Generator
|
||||
|
||||
def user(opts \\ []) do
|
||||
changeset_generator(
|
||||
User,
|
||||
:create,
|
||||
defaults: [
|
||||
email: "user-#{System.unique_integer([:positive])}@example.com",
|
||||
username: "user_#{System.unique_integer([:positive])}"
|
||||
],
|
||||
overrides: opts
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# In your tests
|
||||
test "concurrent user creation" do
|
||||
users = MyApp.TestGenerators.generate_many(user(), 10)
|
||||
# Each user has unique identity attributes
|
||||
end
|
||||
```
|
||||
|
||||
This applies to ANY field used in identity constraints, not just primary keys. Using globally unique values prevents frustrating intermittent test failures in CI environments.
|
||||
Reference in New Issue
Block a user