Compare commits
6 Commits
7e0d7d8888
...
0f41e86cf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f41e86cf0 | |||
| 0ac0b68029 | |||
| ae35600822 | |||
| 1ea0d232a4 | |||
| 53467cd611 | |||
| 1c1830b086 |
107
.agents/skills/ash-framework/SKILL.md
Normal file
107
.agents/skills/ash-framework/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: ash-framework
|
||||
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes."
|
||||
metadata:
|
||||
managed-by: usage-rules
|
||||
---
|
||||
|
||||
<!-- usage-rules-skill-start -->
|
||||
## Additional References
|
||||
|
||||
- [actions](references/actions.md)
|
||||
- [aggregates](references/aggregates.md)
|
||||
- [authorization](references/authorization.md)
|
||||
- [calculations](references/calculations.md)
|
||||
- [code_interfaces](references/code_interfaces.md)
|
||||
- [code_structure](references/code_structure.md)
|
||||
- [data_layers](references/data_layers.md)
|
||||
- [exist_expressions](references/exist_expressions.md)
|
||||
- [generating_code](references/generating_code.md)
|
||||
- [migrations](references/migrations.md)
|
||||
- [query_filter](references/query_filter.md)
|
||||
- [querying_data](references/querying_data.md)
|
||||
- [relationships](references/relationships.md)
|
||||
- [testing](references/testing.md)
|
||||
- [ash](references/ash.md)
|
||||
- [ash_admin](references/ash_admin.md)
|
||||
- [ash_ai](references/ash_ai.md)
|
||||
- [ash_authentication](references/ash_authentication.md)
|
||||
- [ash_authentication_phoenix](references/ash_authentication_phoenix.md)
|
||||
- [ash_graphql](references/ash_graphql.md)
|
||||
- [ash_json_api](references/ash_json_api.md)
|
||||
- [ash_phoenix](references/ash_phoenix.md)
|
||||
- [ash_postgres](references/ash_postgres.md)
|
||||
- [ash_state_machine](references/ash_state_machine.md)
|
||||
- [ash_typescript](references/ash_typescript.md)
|
||||
|
||||
## Searching Documentation
|
||||
|
||||
```sh
|
||||
mix usage_rules.search_docs "search term" -p ash -p ash_admin -p ash_ai -p ash_authentication -p ash_authentication_phoenix -p ash_graphql -p ash_json_api -p ash_phoenix -p ash_postgres -p ash_state_machine -p ash_typescript
|
||||
```
|
||||
|
||||
## Available Mix Tasks
|
||||
|
||||
- `mix ash` - Prints Ash help information
|
||||
- `mix ash.codegen` - Runs all codegen tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash.extend` - Adds an extension or extensions to the given domain/resource
|
||||
- `mix ash.gen.base_resource` - Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency.
|
||||
- `mix ash.gen.change` - Generates a custom change module.
|
||||
- `mix ash.gen.custom_expression` - Generates a custom expression module.
|
||||
- `mix ash.gen.domain` - Generates an Ash.Domain
|
||||
- `mix ash.gen.enum` - Generates an Ash.Type.Enum
|
||||
- `mix ash.gen.gettext` - Copies Ash's .pot file for error message translation
|
||||
- `mix ash.gen.preparation` - Generates a custom preparation module.
|
||||
- `mix ash.gen.resource` - Generate and configure an Ash.Resource.
|
||||
- `mix ash.gen.validation` - Generates a custom validation module.
|
||||
- `mix ash.generate_livebook` - Generates a Livebook for each Ash domain
|
||||
- `mix ash.generate_policy_charts` - Generates a Mermaid Flow Chart for a given resource's policies.
|
||||
- `mix ash.generate_resource_diagrams` - Generates Mermaid Resource Diagrams for each Ash domain
|
||||
- `mix ash.gettext.extract` - Extracts Ash error messages into a .pot file
|
||||
- `mix ash.install` - Installs Ash into a project. Should be called with `mix igniter.install ash`
|
||||
- `mix ash.migrate` - Runs all migration tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash.patch.extend` - Adds an extension or extensions to the given domain/resource
|
||||
- `mix ash.reset` - Runs all tear down & setup tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash.rollback` - Runs all rollback tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash.setup` - Runs all setup tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash.tear_down` - Runs all tear_down tasks for any extension on any resource/domain in your application.
|
||||
- `mix ash_admin.install` - Installs AshAdmin
|
||||
- `mix ash_admin.install.docs`
|
||||
- `mix ash_ai.gen.chat` - Generates the resources and views for a conversational UI backed by `ash_postgres` and `ash_oban`
|
||||
- `mix ash_ai.gen.chat.docs`
|
||||
- `mix ash_ai.gen.mcp` - Sets up an MCP server for your application
|
||||
- `mix ash_ai.gen.mcp.docs`
|
||||
- `mix ash_ai.gen.usage_rules`
|
||||
- `mix ash_ai.gen.usage_rules.docs`
|
||||
- `mix ash_ai.install` - Installs `AshAi`. Call with `mix igniter.install ash_ai`. Requires igniter to run.
|
||||
- `mix ash_ai.install.docs`
|
||||
- `mix ash_authentication.add_add_on` - Adds the provided add-on to your user resource
|
||||
- `mix ash_authentication.add_strategy` - Adds the provided strategy or strategies to your user resource
|
||||
- `mix ash_authentication.install` - Installs AshAuthentication. Invoke with `mix igniter.install ash_authentication`
|
||||
- `mix ash_authentication.upgrade`
|
||||
- `mix ash_authentication.phoenix.routes` - Prints all routes generated by AshAuthentication Phoenix
|
||||
- `mix ash_authentication_phoenix.install` - Installs AshAuthenticationPhoenix. Invoke with `mix igniter.install ash_authentication_phoenix`
|
||||
- `mix ash_authentication_phoenix.upgrade`
|
||||
- `mix ash_graphql.install` - Installs AshGraphql. Should be run with `mix igniter.install ash_graphql`
|
||||
- `mix ash_json_api.install` - Installs AshJsonApi. Should be run with `mix igniter.install ash_json_api`
|
||||
- `mix ash_json_api.routes` - Prints all routes by AshJsonApiRouter
|
||||
- `mix ash_phoenix.gen.html` - Generates a controller and HTML views for an existing Ash resource.
|
||||
- `mix ash_phoenix.gen.live` - Generates liveviews for a given domain and resource.
|
||||
- `mix ash_phoenix.install` - Installs AshPhoenix into a project. Should be called with `mix igniter.install ash_phoenix`
|
||||
- `mix ash_postgres.create` - Creates the repository storage
|
||||
- `mix ash_postgres.drop` - Drops the repository storage for the repos in the specified (or configured) domains
|
||||
- `mix ash_postgres.gen.resources` - Generates resources based on a database schema
|
||||
- `mix ash_postgres.generate_migrations` - Generates migrations, and stores a snapshot of your resources
|
||||
- `mix ash_postgres.install` - Installs AshPostgres. Should be run with `mix igniter.install ash_postgres`
|
||||
- `mix ash_postgres.migrate` - Runs the repository migrations for all repositories in the provided (or configured) domains
|
||||
- `mix ash_postgres.rollback` - Rolls back the repository migrations for all repositories in the provided (or configured) domains
|
||||
- `mix ash_postgres.setup_vector` - Sets up pgvector for AshPostgres
|
||||
- `mix ash_postgres.setup_vector.docs`
|
||||
- `mix ash_postgres.squash_snapshots` - Cleans snapshots folder, leaving only one snapshot per resource
|
||||
- `mix ash_state_machine.generate_flow_charts` - Generates Mermaid Flow Charts for each resource using `AshStateMachine`
|
||||
- `mix ash_state_machine.install` - Installs AshStateMachine
|
||||
- `mix ash_state_machine.install.docs`
|
||||
- `mix ash_typescript.codegen` - Generates TypeScript types for Ash Rpc-calls
|
||||
- `mix ash_typescript.install` - Installs AshTypescript into a project. Should be called with `mix igniter.install ash_typescript`
|
||||
- `mix ash_typescript.npm_install`
|
||||
<!-- usage-rules-skill-end -->
|
||||
401
.agents/skills/ash-framework/references/actions.md
Normal file
401
.agents/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
.agents/skills/ash-framework/references/aggregates.md
Normal file
105
.agents/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
.agents/skills/ash-framework/references/ash.md
Normal file
5
.agents/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
.agents/skills/ash-framework/references/ash_ai.md
Normal file
516
.agents/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
.agents/skills/ash-framework/references/ash_authentication.md
Normal file
372
.agents/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
.agents/skills/ash-framework/references/ash_graphql.md
Normal file
5
.agents/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
.agents/skills/ash-framework/references/ash_json_api.md
Normal file
109
.agents/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
.agents/skills/ash-framework/references/ash_phoenix.md
Normal file
5
.agents/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
.agents/skills/ash-framework/references/ash_postgres.md
Normal file
7
.agents/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
.agents/skills/ash-framework/references/ash_typescript.md
Normal file
412
.agents/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
.agents/skills/ash-framework/references/authorization.md
Normal file
180
.agents/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
.agents/skills/ash-framework/references/calculations.md
Normal file
149
.agents/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
.agents/skills/ash-framework/references/code_interfaces.md
Normal file
134
.agents/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
.agents/skills/ash-framework/references/data_layers.md
Normal file
44
.agents/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
.agents/skills/ash-framework/references/exist_expressions.md
Normal file
31
.agents/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
.agents/skills/ash-framework/references/migrations.md
Normal file
3
.agents/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
.agents/skills/ash-framework/references/query_filter.md
Normal file
28
.agents/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
.agents/skills/ash-framework/references/querying_data.md
Normal file
3
.agents/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
.agents/skills/ash-framework/references/relationships.md
Normal file
170
.agents/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
.agents/skills/ash-framework/references/testing.md
Normal file
59
.agents/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.
|
||||
56
.agents/skills/phoenix-framework/SKILL.md
Normal file
56
.agents/skills/phoenix-framework/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: phoenix-framework
|
||||
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc."
|
||||
metadata:
|
||||
managed-by: usage-rules
|
||||
---
|
||||
|
||||
<!-- usage-rules-skill-start -->
|
||||
## Additional References
|
||||
|
||||
- [ecto](references/ecto.md)
|
||||
- [elixir](references/elixir.md)
|
||||
- [html](references/html.md)
|
||||
- [liveview](references/liveview.md)
|
||||
- [phoenix](references/phoenix.md)
|
||||
- [phoenix_ecto](references/phoenix_ecto.md)
|
||||
- [phoenix_html](references/phoenix_html.md)
|
||||
- [phoenix_live_dashboard](references/phoenix_live_dashboard.md)
|
||||
- [phoenix_live_reload](references/phoenix_live_reload.md)
|
||||
- [phoenix_live_view](references/phoenix_live_view.md)
|
||||
|
||||
## Searching Documentation
|
||||
|
||||
```sh
|
||||
mix usage_rules.search_docs "search term" -p phoenix -p phoenix_ecto -p phoenix_html -p phoenix_live_dashboard -p phoenix_live_reload -p phoenix_live_view
|
||||
```
|
||||
|
||||
## Available Mix Tasks
|
||||
|
||||
- `mix compile.phoenix`
|
||||
- `mix phx` - Prints Phoenix help information
|
||||
- `mix phx.digest` - Digests and compresses static files
|
||||
- `mix phx.digest.clean` - Removes old versions of static assets.
|
||||
- `mix phx.gen` - Lists all available Phoenix generators
|
||||
- `mix phx.gen.auth` - Generates authentication logic for a resource
|
||||
- `mix phx.gen.auth.hashing_library`
|
||||
- `mix phx.gen.auth.injector`
|
||||
- `mix phx.gen.auth.migration`
|
||||
- `mix phx.gen.cert` - Generates a self-signed certificate for HTTPS testing
|
||||
- `mix phx.gen.channel` - Generates a Phoenix channel
|
||||
- `mix phx.gen.context` - Generates a context with functions around an Ecto schema
|
||||
- `mix phx.gen.embedded` - Generates an embedded Ecto schema file
|
||||
- `mix phx.gen.html` - Generates context and controller for an HTML resource
|
||||
- `mix phx.gen.json` - Generates context and controller for a JSON resource
|
||||
- `mix phx.gen.live` - Generates LiveView, templates, and context for a resource
|
||||
- `mix phx.gen.notifier` - Generates a notifier that delivers emails by default
|
||||
- `mix phx.gen.presence` - Generates a Presence tracker
|
||||
- `mix phx.gen.release` - Generates release files and optional Dockerfile for release-based deployments
|
||||
- `mix phx.gen.schema` - Generates an Ecto schema and migration file
|
||||
- `mix phx.gen.secret` - Generates a secret
|
||||
- `mix phx.gen.socket` - Generates a Phoenix socket handler
|
||||
- `mix phx.routes` - Prints all routes
|
||||
- `mix phx.server` - Starts applications and their servers
|
||||
- `mix compile.phoenix_live_view`
|
||||
- `mix phoenix_live_view.upgrade`
|
||||
<!-- usage-rules-skill-end -->
|
||||
9
.agents/skills/phoenix-framework/references/ecto.md
Normal file
9
.agents/skills/phoenix-framework/references/ecto.md
Normal file
@@ -0,0 +1,9 @@
|
||||
## Ecto Guidelines
|
||||
|
||||
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
|
||||
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
|
||||
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
|
||||
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
|
||||
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
|
||||
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
|
||||
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
|
||||
54
.agents/skills/phoenix-framework/references/elixir.md
Normal file
54
.agents/skills/phoenix-framework/references/elixir.md
Normal file
@@ -0,0 +1,54 @@
|
||||
## Elixir guidelines
|
||||
|
||||
- Elixir lists **do not support index based access via the access syntax**
|
||||
|
||||
**Never do this (invalid)**:
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
mylist[i]
|
||||
|
||||
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
Enum.at(mylist, i)
|
||||
|
||||
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||
|
||||
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||
if connected?(socket) do
|
||||
socket = assign(socket, :val, val)
|
||||
end
|
||||
|
||||
# VALID: we rebind the result of the `if` to a new variable
|
||||
socket =
|
||||
if connected?(socket) do
|
||||
assign(socket, :val, val)
|
||||
end
|
||||
|
||||
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||
|
||||
## Mix guidelines
|
||||
|
||||
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||
|
||||
## Test guidelines
|
||||
|
||||
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
|
||||
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
|
||||
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
|
||||
|
||||
ref = Process.monitor(pid)
|
||||
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||
|
||||
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
|
||||
76
.agents/skills/phoenix-framework/references/html.md
Normal file
76
.agents/skills/phoenix-framework/references/html.md
Normal file
@@ -0,0 +1,76 @@
|
||||
## Phoenix HTML guidelines
|
||||
|
||||
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
|
||||
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
|
||||
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
|
||||
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
|
||||
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
|
||||
|
||||
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
|
||||
|
||||
**Never do this (invalid)**:
|
||||
|
||||
<%= if condition do %>
|
||||
...
|
||||
<% else if other_condition %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
Instead **always** do this:
|
||||
|
||||
<%= cond do %>
|
||||
<% condition -> %>
|
||||
...
|
||||
<% condition2 -> %>
|
||||
...
|
||||
<% true -> %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
|
||||
|
||||
<code phx-no-curly-interpolation>
|
||||
let obj = {key: "val"}
|
||||
</code>
|
||||
|
||||
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
|
||||
|
||||
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
|
||||
|
||||
<a class={[
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5",
|
||||
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
|
||||
...
|
||||
]}>Text</a>
|
||||
|
||||
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
|
||||
|
||||
and **never** do this, since it's invalid (note the missing `[` and `]`):
|
||||
|
||||
<a class={
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5"
|
||||
}> ...
|
||||
=> Raises compile syntax error on invalid HEEx attr syntax
|
||||
|
||||
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
|
||||
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
|
||||
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
|
||||
|
||||
**Always** do this:
|
||||
|
||||
<div id={@id}>
|
||||
{@my_assign}
|
||||
<%= if @some_block_condition do %>
|
||||
{@another_assign}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
and **Never** do this – the program will terminate with a syntax error:
|
||||
|
||||
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
|
||||
<div id="<%= @invalid_interpolation %>">
|
||||
{if @invalid_block_construct do}
|
||||
{end}
|
||||
</div>
|
||||
231
.agents/skills/phoenix-framework/references/liveview.md
Normal file
231
.agents/skills/phoenix-framework/references/liveview.md
Normal file
@@ -0,0 +1,231 @@
|
||||
## Phoenix LiveView guidelines
|
||||
|
||||
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
|
||||
- **Avoid LiveComponent's** unless you have a strong, specific need for them
|
||||
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
|
||||
|
||||
### LiveView streams
|
||||
|
||||
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
|
||||
- basic append of N items - `stream(socket, :messages, [new_msg])`
|
||||
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
|
||||
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
|
||||
- deleting items - `stream_delete(socket, :messages, msg)`
|
||||
|
||||
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, msg} <- @streams.messages} id={id}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
|
||||
|
||||
def handle_event("filter", %{"filter" => filter}, socket) do
|
||||
# re-fetch the messages based on the filter
|
||||
messages = list_messages(filter)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:messages_empty?, messages == [])
|
||||
# reset the stream with the new messages
|
||||
|> stream(:messages, messages, reset: true)}
|
||||
end
|
||||
|
||||
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
|
||||
|
||||
<div id="tasks" phx-update="stream">
|
||||
<div class="hidden only:block">No tasks yet</div>
|
||||
<div :for={{id, task} <- @streams.tasks} id={id}>
|
||||
{task.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
|
||||
|
||||
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
|
||||
along with the updated assign:
|
||||
|
||||
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
|
||||
message = Chat.get_message!(message_id)
|
||||
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
|
||||
|
||||
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:messages, message)
|
||||
|> assign(:editing_message_id, String.to_integer(message_id))
|
||||
|> assign(:edit_form, edit_form)}
|
||||
end
|
||||
|
||||
And in the template:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
|
||||
{message.username}
|
||||
<%= if @editing_message_id == message.id do %>
|
||||
<%!-- Edit mode --%>
|
||||
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
|
||||
...
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
|
||||
|
||||
### LiveView JavaScript interop
|
||||
|
||||
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
|
||||
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
|
||||
|
||||
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
|
||||
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
|
||||
|
||||
#### Inline colocated js hooks
|
||||
|
||||
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
|
||||
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
|
||||
when writing scripts inside the template**:
|
||||
|
||||
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
|
||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
|
||||
export default {
|
||||
mounted() {
|
||||
this.el.addEventListener("input", e => {
|
||||
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
|
||||
if(match) {
|
||||
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
- colocated hooks are automatically integrated into the app.js bundle
|
||||
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
|
||||
|
||||
#### External phx-hook
|
||||
|
||||
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
|
||||
LiveSocket constructor:
|
||||
|
||||
const MyHook = {
|
||||
mounted() { ... }
|
||||
}
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
hooks: { MyHook }
|
||||
});
|
||||
|
||||
#### Pushing events between client and server
|
||||
|
||||
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
|
||||
**Always** return or rebind the socket on `push_event/3` when pushing events:
|
||||
|
||||
# re-bind socket so we maintain event state to be pushed
|
||||
socket = push_event(socket, "my_event", %{...})
|
||||
|
||||
# or return the modified socket directly:
|
||||
def handle_event("some_event", _, socket) do
|
||||
{:noreply, push_event(socket, "my_event", %{...})}
|
||||
end
|
||||
|
||||
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
|
||||
|
||||
mounted() {
|
||||
this.handleEvent("my_event", data => console.log("from server:", data));
|
||||
}
|
||||
|
||||
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("click", e => {
|
||||
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
|
||||
})
|
||||
}
|
||||
|
||||
Where the server handled it via:
|
||||
|
||||
def handle_event("my_event", %{"one" => 1}, socket) do
|
||||
{:reply, %{two: 2}, socket}
|
||||
end
|
||||
|
||||
### LiveView tests
|
||||
|
||||
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
|
||||
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
|
||||
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
|
||||
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
|
||||
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
|
||||
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
|
||||
- Focus on testing outcomes rather than implementation details
|
||||
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
|
||||
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
|
||||
|
||||
html = render(view)
|
||||
document = LazyHTML.from_fragment(html)
|
||||
matches = LazyHTML.filter(document, "your-complex-selector")
|
||||
IO.inspect(matches, label: "Matches")
|
||||
|
||||
### Form handling
|
||||
|
||||
#### Creating a form from params
|
||||
|
||||
If you want to create a form based on `handle_event` params:
|
||||
|
||||
def handle_event("submitted", params, socket) do
|
||||
{:noreply, assign(socket, form: to_form(params))}
|
||||
end
|
||||
|
||||
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
|
||||
|
||||
You can also specify a name to nest the params:
|
||||
|
||||
def handle_event("submitted", %{"user" => user_params}, socket) do
|
||||
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
|
||||
end
|
||||
|
||||
#### Creating a form from changesets
|
||||
|
||||
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
|
||||
|
||||
defmodule MyApp.Users.User do
|
||||
use Ecto.Schema
|
||||
...
|
||||
end
|
||||
|
||||
And then you create a changeset that you pass to `to_form`:
|
||||
|
||||
%MyApp.Users.User{}
|
||||
|> Ecto.Changeset.change()
|
||||
|> to_form()
|
||||
|
||||
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
|
||||
|
||||
In the template, the form form assign can be passed to the `<.form>` function component:
|
||||
|
||||
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
|
||||
|
||||
#### Avoiding form errors
|
||||
|
||||
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
|
||||
|
||||
<%!-- ALWAYS do this (valid) --%>
|
||||
<.form for={@form} id="my-form">
|
||||
<.input field={@form[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
And **never** do this:
|
||||
|
||||
<%!-- NEVER do this (invalid) --%>
|
||||
<.form for={@changeset} id="my-form">
|
||||
<.input field={@changeset[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
|
||||
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
|
||||
15
.agents/skills/phoenix-framework/references/phoenix.md
Normal file
15
.agents/skills/phoenix-framework/references/phoenix.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Phoenix guidelines
|
||||
|
||||
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||
|
||||
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||
|
||||
scope "/admin", AppWeb.Admin do
|
||||
pipe_through :browser
|
||||
|
||||
live "/users", UserLive, :index
|
||||
end
|
||||
|
||||
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||
|
||||
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||
438
AGENTS.md
438
AGENTS.md
@@ -45,329 +45,137 @@ custom classes must fully style the input
|
||||
|
||||
|
||||
<!-- usage-rules-start -->
|
||||
<!-- usage_rules-start -->
|
||||
## usage_rules usage
|
||||
_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_
|
||||
|
||||
<!-- phoenix:elixir-start -->
|
||||
## Elixir guidelines
|
||||
## Using Usage Rules
|
||||
|
||||
- Elixir lists **do not support index based access via the access syntax**
|
||||
Many packages have usage rules, which you should *thoroughly* consult before taking any
|
||||
action. These usage rules contain guidelines and rules *directly from the package authors*.
|
||||
They are your best source of knowledge for making decisions.
|
||||
|
||||
**Never do this (invalid)**:
|
||||
## Modules & functions in the current app and dependencies
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
mylist[i]
|
||||
When looking for docs for modules & functions that are dependencies of the current project,
|
||||
or for Elixir itself, use `mix usage_rules.docs`
|
||||
|
||||
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||
```
|
||||
# Search a whole module
|
||||
mix usage_rules.docs Enum
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
Enum.at(mylist, i)
|
||||
# Search a specific function
|
||||
mix usage_rules.docs Enum.zip
|
||||
|
||||
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||
# Search a specific function & arity
|
||||
mix usage_rules.docs Enum.zip/1
|
||||
```
|
||||
|
||||
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||
if connected?(socket) do
|
||||
socket = assign(socket, :val, val)
|
||||
end
|
||||
|
||||
# VALID: we rebind the result of the `if` to a new variable
|
||||
socket =
|
||||
if connected?(socket) do
|
||||
assign(socket, :val, val)
|
||||
end
|
||||
## Searching Documentation
|
||||
|
||||
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||
You should also consult the documentation of any tools you are using, early and often. The best
|
||||
way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have
|
||||
found what you are looking for, use the links in the search results to get more detail. For example:
|
||||
|
||||
```
|
||||
# Search docs for all packages in the current application, including Elixir
|
||||
mix usage_rules.search_docs Enum.zip
|
||||
|
||||
# Search docs for specific packages
|
||||
mix usage_rules.search_docs Req.get -p req
|
||||
|
||||
# Search docs for multi-word queries
|
||||
mix usage_rules.search_docs "making requests" -p req
|
||||
|
||||
# Search only in titles (useful for finding specific functions/modules)
|
||||
mix usage_rules.search_docs "Enum.zip" --query-by title
|
||||
```
|
||||
|
||||
|
||||
<!-- usage_rules-end -->
|
||||
<!-- usage_rules:elixir-start -->
|
||||
## usage_rules:elixir usage
|
||||
# Elixir Core Usage Rules
|
||||
|
||||
## Pattern Matching
|
||||
- Use pattern matching over conditional logic when possible
|
||||
- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies
|
||||
- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps
|
||||
|
||||
## Error Handling
|
||||
- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail
|
||||
- Avoid raising exceptions for control flow
|
||||
- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned.
|
||||
- Don't use `Enum` functions on large collections when `Stream` is more appropriate
|
||||
- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions
|
||||
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||
|
||||
## Mix guidelines
|
||||
|
||||
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||
|
||||
## Test guidelines
|
||||
|
||||
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
|
||||
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
|
||||
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
|
||||
|
||||
ref = Process.monitor(pid)
|
||||
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||
|
||||
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
|
||||
<!-- phoenix:elixir-end -->
|
||||
|
||||
<!-- phoenix:phoenix-start -->
|
||||
## Phoenix guidelines
|
||||
|
||||
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||
|
||||
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||
|
||||
scope "/admin", AppWeb.Admin do
|
||||
pipe_through :browser
|
||||
|
||||
live "/users", UserLive, :index
|
||||
end
|
||||
|
||||
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||
|
||||
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||
<!-- phoenix:phoenix-end -->
|
||||
|
||||
|
||||
<!-- phoenix:html-start -->
|
||||
## Phoenix HTML guidelines
|
||||
|
||||
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
|
||||
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
|
||||
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
|
||||
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
|
||||
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
|
||||
|
||||
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
|
||||
|
||||
**Never do this (invalid)**:
|
||||
|
||||
<%= if condition do %>
|
||||
...
|
||||
<% else if other_condition %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
Instead **always** do this:
|
||||
|
||||
<%= cond do %>
|
||||
<% condition -> %>
|
||||
...
|
||||
<% condition2 -> %>
|
||||
...
|
||||
<% true -> %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
|
||||
|
||||
<code phx-no-curly-interpolation>
|
||||
let obj = {key: "val"}
|
||||
</code>
|
||||
|
||||
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
|
||||
|
||||
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
|
||||
|
||||
<a class={[
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5",
|
||||
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
|
||||
...
|
||||
]}>Text</a>
|
||||
|
||||
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
|
||||
|
||||
and **never** do this, since it's invalid (note the missing `[` and `]`):
|
||||
|
||||
<a class={
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5"
|
||||
}> ...
|
||||
=> Raises compile syntax error on invalid HEEx attr syntax
|
||||
|
||||
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
|
||||
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
|
||||
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
|
||||
|
||||
**Always** do this:
|
||||
|
||||
<div id={@id}>
|
||||
{@my_assign}
|
||||
<%= if @some_block_condition do %>
|
||||
{@another_assign}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
and **Never** do this – the program will terminate with a syntax error:
|
||||
|
||||
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
|
||||
<div id="<%= @invalid_interpolation %>">
|
||||
{if @invalid_block_construct do}
|
||||
{end}
|
||||
</div>
|
||||
<!-- phoenix:html-end -->
|
||||
|
||||
<!-- phoenix:liveview-start -->
|
||||
## Phoenix LiveView guidelines
|
||||
|
||||
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
|
||||
- **Avoid LiveComponent's** unless you have a strong, specific need for them
|
||||
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
|
||||
|
||||
### LiveView streams
|
||||
|
||||
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
|
||||
- basic append of N items - `stream(socket, :messages, [new_msg])`
|
||||
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
|
||||
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
|
||||
- deleting items - `stream_delete(socket, :messages, msg)`
|
||||
|
||||
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, msg} <- @streams.messages} id={id}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
|
||||
|
||||
def handle_event("filter", %{"filter" => filter}, socket) do
|
||||
# re-fetch the messages based on the filter
|
||||
messages = list_messages(filter)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:messages_empty?, messages == [])
|
||||
# reset the stream with the new messages
|
||||
|> stream(:messages, messages, reset: true)}
|
||||
end
|
||||
|
||||
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
|
||||
|
||||
<div id="tasks" phx-update="stream">
|
||||
<div class="hidden only:block">No tasks yet</div>
|
||||
<div :for={{id, task} <- @streams.tasks} id={id}>
|
||||
{task.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
|
||||
|
||||
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
|
||||
along with the updated assign:
|
||||
|
||||
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
|
||||
message = Chat.get_message!(message_id)
|
||||
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
|
||||
|
||||
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:messages, message)
|
||||
|> assign(:editing_message_id, String.to_integer(message_id))
|
||||
|> assign(:edit_form, edit_form)}
|
||||
end
|
||||
|
||||
And in the template:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
|
||||
{message.username}
|
||||
<%= if @editing_message_id == message.id do %>
|
||||
<%!-- Edit mode --%>
|
||||
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
|
||||
...
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
|
||||
|
||||
### LiveView JavaScript interop
|
||||
|
||||
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
|
||||
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
|
||||
|
||||
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
|
||||
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
|
||||
|
||||
#### Inline colocated js hooks
|
||||
|
||||
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
|
||||
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
|
||||
when writing scripts inside the template**:
|
||||
|
||||
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
|
||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
|
||||
export default {
|
||||
mounted() {
|
||||
this.el.addEventListener("input", e => {
|
||||
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
|
||||
if(match) {
|
||||
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
- colocated hooks are automatically integrated into the app.js bundle
|
||||
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
|
||||
|
||||
#### External phx-hook
|
||||
|
||||
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
|
||||
LiveSocket constructor:
|
||||
|
||||
const MyHook = {
|
||||
mounted() { ... }
|
||||
}
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
hooks: { MyHook }
|
||||
});
|
||||
|
||||
#### Pushing events between client and server
|
||||
|
||||
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
|
||||
**Always** return or rebind the socket on `push_event/3` when pushing events:
|
||||
|
||||
# re-bind socket so we maintain event state to be pushed
|
||||
socket = push_event(socket, "my_event", %{...})
|
||||
|
||||
# or return the modified socket directly:
|
||||
def handle_event("some_event", _, socket) do
|
||||
{:noreply, push_event(socket, "my_event", %{...})}
|
||||
end
|
||||
|
||||
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
|
||||
|
||||
mounted() {
|
||||
this.handleEvent("my_event", data => console.log("from server:", data));
|
||||
}
|
||||
|
||||
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("click", e => {
|
||||
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
|
||||
})
|
||||
}
|
||||
|
||||
Where the server handled it via:
|
||||
|
||||
def handle_event("my_event", %{"one" => 1}, socket) do
|
||||
{:reply, %{two: 2}, socket}
|
||||
end
|
||||
|
||||
### LiveView tests
|
||||
|
||||
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
|
||||
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
|
||||
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
|
||||
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
|
||||
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
|
||||
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
|
||||
- Focus on testing outcomes rather than implementation details
|
||||
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
|
||||
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
|
||||
|
||||
html = render(view)
|
||||
document = LazyHTML.from_fragment(html)
|
||||
matches = LazyHTML.filter(document, "your-complex-selector")
|
||||
IO.inspect(matches, label: "Matches")
|
||||
- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions
|
||||
- Prefer `Enum` functions like `Enum.reduce` over recursion
|
||||
- When recursion is necessary, prefer to use pattern matching in function heads for base case detection
|
||||
- Using the process dictionary is typically a sign of unidiomatic code
|
||||
- Only use macros if explicitly requested
|
||||
- There are many useful standard library functions, prefer to use them where possible
|
||||
|
||||
## Function Design
|
||||
- Use guard clauses: `when is_binary(name) and byte_size(name) > 0`
|
||||
- Prefer multiple function clauses over complex conditional logic
|
||||
- Name functions descriptively: `calculate_total_price/2` not `calc/2`
|
||||
- Predicate function names should not start with `is` and should end in a question mark.
|
||||
- Names like `is_thing` should be reserved for guards
|
||||
|
||||
## Data Structures
|
||||
- Use structs over maps when the shape is known: `defstruct [:name, :age]`
|
||||
- Prefer keyword lists for options: `[timeout: 5000, retries: 3]`
|
||||
- Use maps for dynamic key-value data
|
||||
- Prefer to prepend to lists `[new | list]` not `list ++ [new]`
|
||||
|
||||
## Mix Tasks
|
||||
|
||||
- Use `mix help` to list available mix tasks
|
||||
- Use `mix help task_name` to get docs for an individual task
|
||||
- Read the docs and options fully before using tasks
|
||||
|
||||
## Testing
|
||||
- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123`
|
||||
- Limit the number of failed tests with `mix test --max-failures n`
|
||||
- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests
|
||||
- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end`
|
||||
- Use `mix help test` to for full documentation on running tests
|
||||
|
||||
## Debugging
|
||||
|
||||
- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console.
|
||||
|
||||
<!-- usage_rules:elixir-end -->
|
||||
<!-- usage_rules:otp-start -->
|
||||
## usage_rules:otp usage
|
||||
# OTP Usage Rules
|
||||
|
||||
## GenServer Best Practices
|
||||
- Keep state simple and serializable
|
||||
- Handle all expected messages explicitly
|
||||
- Use `handle_continue/2` for post-init work
|
||||
- Implement proper cleanup in `terminate/2` when necessary
|
||||
|
||||
## Process Communication
|
||||
- Use `GenServer.call/3` for synchronous requests expecting replies
|
||||
- Use `GenServer.cast/2` for fire-and-forget messages.
|
||||
- When in doubt, use `call` over `cast`, to ensure back-pressure
|
||||
- Set appropriate timeouts for `call/3` operations
|
||||
|
||||
## Fault Tolerance
|
||||
- Set up processes such that they can handle crashing and being restarted by supervisors
|
||||
- Use `:max_restarts` and `:max_seconds` to prevent restart loops
|
||||
|
||||
## Task and Async
|
||||
- Use `Task.Supervisor` for better fault tolerance
|
||||
- Handle task failures with `Task.yield/2` or `Task.shutdown/2`
|
||||
- Set appropriate task timeouts
|
||||
- Use `Task.async_stream/3` for concurrent enumeration with back-pressure
|
||||
|
||||
<!-- usage_rules:otp-end -->
|
||||
<!-- usage-rules-end -->
|
||||
|
||||
@@ -474,6 +474,46 @@ html, body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mx-tweet-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.mx-like-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--mx-border2);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklch, var(--mx-surface2) 72%, transparent);
|
||||
color: var(--mx-fg2);
|
||||
cursor: pointer;
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
padding: 0.45rem 0.75rem;
|
||||
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.mx-like-btn:hover:not(:disabled) {
|
||||
color: var(--mx-red);
|
||||
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
|
||||
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mx-like-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mx-like-btn-active {
|
||||
color: var(--mx-red);
|
||||
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
|
||||
background: color-mix(in oklch, var(--mx-red) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ── Edit ── */
|
||||
.mx-edit-area { margin-top: 0.25rem; }
|
||||
|
||||
@@ -581,3 +621,116 @@ html, body {
|
||||
border: 1px solid var(--mx-border2);
|
||||
color: var(--mx-accent2);
|
||||
}
|
||||
|
||||
/* ── Tweet Detail Page ── */
|
||||
.mx-detail {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.mx-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mx-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--mx-fg2);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: var(--mx-radius-sm);
|
||||
border: 1px solid var(--mx-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mx-back-btn:hover { background: var(--mx-surface2); }
|
||||
|
||||
.mx-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mx-detail-body {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.mx-detail-content {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-fg);
|
||||
margin-bottom: 1rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mx-detail-media {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Clickable media thumb (used in detail view) ── */
|
||||
.mx-media-thumb {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mx-radius-sm);
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.mx-media-thumb img,
|
||||
.mx-media-thumb video {
|
||||
width: 100%;
|
||||
border-radius: var(--mx-radius-sm);
|
||||
display: block;
|
||||
}
|
||||
.mx-media-thumb:hover { opacity: 0.85; }
|
||||
|
||||
/* ── Media Lightbox ── */
|
||||
.mx-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mx-lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mx-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }
|
||||
|
||||
.mx-lightbox-content {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.mx-lightbox-media {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: var(--mx-radius-sm);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -435,6 +435,74 @@ export async function validateDestroyTweet(
|
||||
}
|
||||
|
||||
|
||||
export type LikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
export type InferLikeTweetResult<
|
||||
Fields extends LikeTweetFields | undefined,
|
||||
> = InferResult<tweetsResourceSchema, Fields>;
|
||||
|
||||
export type LikeTweetResult<Fields extends LikeTweetFields | undefined = undefined> = | { success: true; data: InferLikeTweetResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
*/
|
||||
export async function likeTweet<Fields extends LikeTweetFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<LikeTweetResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "like_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<LikeTweetResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateLikeTweet(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID | string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "like_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
|
||||
@@ -536,11 +604,76 @@ export async function validateReadTweet(
|
||||
}
|
||||
|
||||
|
||||
export type UnlikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
export type InferUnlikeTweetResult<
|
||||
Fields extends UnlikeTweetFields | undefined,
|
||||
> = InferResult<tweetsResourceSchema, Fields>;
|
||||
|
||||
export type UnlikeTweetResult<Fields extends UnlikeTweetFields | undefined = undefined> = | { success: true; data: InferUnlikeTweetResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
*/
|
||||
export async function unlikeTweet<Fields extends UnlikeTweetFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<UnlikeTweetResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "unlike_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<UnlikeTweetResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Update an existing Tweet
|
||||
*
|
||||
* @ashActionType :update
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateUnlikeTweet(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID | string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "unlike_tweet",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type UpdateTweetInput = {
|
||||
content?: string;
|
||||
likes?: number;
|
||||
userId?: UUID;
|
||||
state?: "posted" | "drafted";
|
||||
};
|
||||
|
||||
export type UpdateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
|
||||
export type UUID = string;
|
||||
export type UtcDateTimeUsec = string;
|
||||
|
||||
// media Schema
|
||||
export type mediaResourceSchema = {
|
||||
@@ -31,12 +32,15 @@ export type mediaAttributesOnlySchema = {
|
||||
// tweets Schema
|
||||
export type tweetsResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail";
|
||||
id: UUID;
|
||||
content: string;
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
insertedAt: UtcDateTimeUsec;
|
||||
state: "posted" | "drafted";
|
||||
likedByMe: boolean;
|
||||
userEmail: string | null;
|
||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||
};
|
||||
|
||||
@@ -44,11 +48,12 @@ export type tweetsResourceSchema = {
|
||||
|
||||
export type tweetsAttributesOnlySchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "state";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state";
|
||||
id: UUID;
|
||||
content: string;
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
insertedAt: UtcDateTimeUsec;
|
||||
state: "posted" | "drafted";
|
||||
};
|
||||
|
||||
@@ -120,12 +125,34 @@ export type tweetsFilterInput = {
|
||||
in?: Array<UUID>;
|
||||
};
|
||||
|
||||
insertedAt?: {
|
||||
eq?: UtcDateTimeUsec;
|
||||
notEq?: UtcDateTimeUsec;
|
||||
greaterThan?: UtcDateTimeUsec;
|
||||
greaterThanOrEqual?: UtcDateTimeUsec;
|
||||
lessThan?: UtcDateTimeUsec;
|
||||
lessThanOrEqual?: UtcDateTimeUsec;
|
||||
in?: Array<UtcDateTimeUsec>;
|
||||
};
|
||||
|
||||
state?: {
|
||||
eq?: "posted" | "drafted";
|
||||
notEq?: "posted" | "drafted";
|
||||
in?: Array<"posted" | "drafted">;
|
||||
};
|
||||
|
||||
userEmail?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
likedByMe?: {
|
||||
eq?: boolean;
|
||||
notEq?: boolean;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
media?: mediaFilterInput;
|
||||
|
||||
@@ -135,14 +162,14 @@ export type tweetsFilterInput = {
|
||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "user", "media"] as const;
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "media"] as const;
|
||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||
|
||||
|
||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] as const;
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe"] as const;
|
||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useRef, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
createTweet,
|
||||
readTweet,
|
||||
destroyTweet,
|
||||
likeTweet,
|
||||
unlikeTweet,
|
||||
updateTweet,
|
||||
buildCSRFHeaders,
|
||||
} from "./ash_rpc";
|
||||
@@ -23,7 +26,17 @@ const queryClient = new QueryClient({
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MediaItem = { id: string; s3Key: string };
|
||||
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
|
||||
type Tweet = {
|
||||
id: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
likedByMe?: boolean;
|
||||
userId: string;
|
||||
state: string;
|
||||
media?: MediaItem[];
|
||||
userEmail?: string | null;
|
||||
insertedAt?: string | null;
|
||||
};
|
||||
|
||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -298,6 +311,7 @@ function TweetMedia({ media }: { media: MediaItem[] }) {
|
||||
function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
const canLike = !!currentUserId;
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(tweet.content);
|
||||
@@ -335,6 +349,23 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({
|
||||
identity: tweet.id,
|
||||
fields: ["id", "likes", "likedByMe"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
function saveEdit() {
|
||||
const trimmed = editText.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -342,13 +373,17 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-tweet">
|
||||
<article
|
||||
className="mx-tweet"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||
>
|
||||
<div className="mx-tweet-avatar">
|
||||
<span>M</span>
|
||||
</div>
|
||||
<div className="mx-tweet-body">
|
||||
<div className="mx-tweet-header">
|
||||
<span className="mx-tweet-handle">@mixer</span>
|
||||
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
||||
<span className="mx-tweet-dot">·</span>
|
||||
<span className="mx-tweet-time">{timeAgo()}</span>
|
||||
{canModify && (
|
||||
@@ -356,7 +391,8 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
<button
|
||||
className="mx-action-btn"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditText(tweet.content);
|
||||
setEditing(true);
|
||||
setConfirmDelete(false);
|
||||
@@ -369,7 +405,8 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
<button
|
||||
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
setTimeout(() => setConfirmDelete(false), 3000);
|
||||
@@ -431,19 +468,248 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
<TweetMedia media={tweet.media} />
|
||||
)}
|
||||
|
||||
<div className="mx-tweet-footer">
|
||||
<button
|
||||
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); likeMutation.mutate(); }}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={
|
||||
canLike
|
||||
? tweet.likedByMe
|
||||
? "Remove like"
|
||||
: "Like post"
|
||||
: "Sign in to like posts"
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void }) {
|
||||
const assetHost = getAssetHost();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div className="mx-lightbox" onClick={onClose}>
|
||||
<button className="mx-lightbox-close" onClick={onClose}>✕</button>
|
||||
<div className="mx-lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||
{/\.(mp4|mov)$/i.test(item.s3Key) ? (
|
||||
<video src={`${assetHost}/${item.s3Key}`} controls autoPlay className="mx-lightbox-media" />
|
||||
) : (
|
||||
<img src={`${assetHost}/${item.s3Key}`} alt="" className="mx-lightbox-media" />
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const assetHost = getAssetHost();
|
||||
|
||||
const { data: tweet, isLoading, isError } = useQuery({
|
||||
queryKey: ["tweet", tweetId],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { id: { eq: tweetId } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweet");
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||
return (results[0] as Tweet) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||
},
|
||||
onSuccess: () => { window.location.href = "/feed"; },
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await updateTweet({
|
||||
identity: tweetId,
|
||||
input: { content },
|
||||
fields: ["id", "content", "userId", "state"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tweet", tweetId] });
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!tweet) return;
|
||||
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !tweet) return <ErrorBanner message="Could not load tweet" />;
|
||||
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
const canLike = !!currentUserId;
|
||||
|
||||
return (
|
||||
<div className="mx-detail">
|
||||
<div className="mx-detail-header">
|
||||
<a href="/feed" className="mx-back-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
{canModify && (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
className="mx-action-btn"
|
||||
title="Edit"
|
||||
onClick={() => { setEditText(tweet.content); setEditing(true); }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||
onClick={() => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
setTimeout(() => setConfirmDelete(false), 3000);
|
||||
} else {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||
) : confirmDelete ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-detail-body">
|
||||
<div className="mx-detail-author">
|
||||
<div className="mx-tweet-avatar">
|
||||
<span>M</span>
|
||||
</div>
|
||||
<span className="mx-tweet-handle">{tweet.userEmail ?? "@mixer"}</span>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="mx-edit-area">
|
||||
<textarea
|
||||
className="mx-edit-textarea"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
autoFocus
|
||||
rows={4}
|
||||
/>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
<div className="mx-edit-footer">
|
||||
<button className="mx-btn-cancel" onClick={() => { setEditing(false); setError(null); }}>Cancel</button>
|
||||
<button
|
||||
className="mx-btn-save"
|
||||
onClick={() => { const t = editText.trim(); if (t) updateMutation.mutate(t); }}
|
||||
disabled={!editText.trim() || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mx-detail-content">{tweet.content}</p>
|
||||
)}
|
||||
|
||||
{tweet.media && tweet.media.length > 0 && (
|
||||
<div className="mx-detail-media">
|
||||
{tweet.media.map((m) => (
|
||||
<button key={m.id} className="mx-media-thumb" onClick={() => setLightboxItem(m)}>
|
||||
{/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||
<video src={`${assetHost}/${m.s3Key}`} />
|
||||
) : (
|
||||
<img src={`${assetHost}/${m.s3Key}`} alt="" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-tweet-footer" style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={() => likeMutation.mutate()}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={canLike ? (tweet.likedByMe ? "Remove like" : "Like post") : "Sign in to like posts"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feed() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ["tweets"],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
|
||||
sort: "-id",
|
||||
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweets");
|
||||
@@ -510,6 +776,7 @@ function App() {
|
||||
const appEl = document.getElementById("app")!;
|
||||
const email = appEl.dataset.currentUserEmail ?? "";
|
||||
const userId = appEl.dataset.currentUserId ?? "";
|
||||
const tweetId = appEl.dataset.tweetId || null;
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ email, userId }}>
|
||||
@@ -521,7 +788,7 @@ function App() {
|
||||
<span className="mx-logo-text">Mixer</span>
|
||||
</div>
|
||||
<nav className="mx-nav">
|
||||
<a className="mx-nav-item mx-nav-active" href="#">
|
||||
<a className="mx-nav-item mx-nav-active" href="/feed">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
@@ -545,25 +812,36 @@ function App() {
|
||||
</aside>
|
||||
|
||||
<main className="mx-main">
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Feed</h1>
|
||||
<RefreshButton />
|
||||
</header>
|
||||
{tweetId ? (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Tweet</h1>
|
||||
</header>
|
||||
<TweetDetail tweetId={tweetId} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Feed</h1>
|
||||
<RefreshButton />
|
||||
</header>
|
||||
|
||||
<div className="mx-compose-wrapper">
|
||||
{email ? (
|
||||
<ComposeTweet />
|
||||
) : (
|
||||
<div className="mx-signin-cta">
|
||||
<p>Sign in to start mixing.</p>
|
||||
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||
<div className="mx-compose-wrapper">
|
||||
{email ? (
|
||||
<ComposeTweet />
|
||||
) : (
|
||||
<div className="mx-signin-cta">
|
||||
<p>Sign in to start mixing.</p>
|
||||
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-divider" />
|
||||
<div className="mx-divider" />
|
||||
|
||||
<Feed />
|
||||
<Feed />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="mx-rightbar">
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
import Config
|
||||
|
||||
config :waffle,
|
||||
storage: Waffle.Storage.S3,
|
||||
bucket: "mixer-bucket",
|
||||
asset_host: "http://localhost:9000"
|
||||
storage: Waffle.Storage.S3
|
||||
|
||||
config :ex_aws,
|
||||
json_codec: Jason
|
||||
|
||||
@@ -102,3 +102,7 @@ config :ex_aws, :s3,
|
||||
host: "localhost",
|
||||
port: 9000,
|
||||
virtual_host: false
|
||||
|
||||
config :waffle,
|
||||
bucket: "mixer-bucket",
|
||||
asset_host: "http://localhost:9000"
|
||||
|
||||
@@ -11,6 +11,7 @@ config :mixer, MixerWeb.Endpoint, cache_static_manifest: "priv/static/cache_mani
|
||||
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
|
||||
# Note `:force_ssl` is required to be set at compile-time.
|
||||
config :mixer, MixerWeb.Endpoint,
|
||||
server: true,
|
||||
force_ssl: [
|
||||
rewrite_on: [:x_forwarded_proto],
|
||||
exclude: [
|
||||
|
||||
@@ -72,6 +72,31 @@ if config_env() == :prod do
|
||||
System.get_env("TOKEN_SIGNING_SECRET") ||
|
||||
raise("Missing environment variable `TOKEN_SIGNING_SECRET`!")
|
||||
|
||||
# Configure S3-compatible storage (MinIO/Garege/AWS S3)
|
||||
config :ex_aws,
|
||||
access_key_id:
|
||||
System.get_env("S3_ACCESS_KEY_ID") ||
|
||||
raise("Missing environment variable `S3_ACCESS_KEY_ID`!"),
|
||||
secret_access_key:
|
||||
System.get_env("S3_SECRET_ACCESS_KEY") ||
|
||||
raise("Missing environment variable `S3_SECRET_ACCESS_KEY`!")
|
||||
|
||||
config :ex_aws, :s3,
|
||||
scheme: System.get_env("S3_SCHEME", "https://"),
|
||||
host:
|
||||
System.get_env("S3_HOST") ||
|
||||
raise("Missing environment variable `S3_HOST`!"),
|
||||
port: String.to_integer(System.get_env("S3_PORT", "80")),
|
||||
virtual_host: System.get_env("S3_VIRTUAL_HOST", "false") == "true"
|
||||
|
||||
config :waffle,
|
||||
bucket:
|
||||
System.get_env("S3_BUCKET") ||
|
||||
raise("Missing environment variable `S3_BUCKET`!"),
|
||||
asset_host:
|
||||
System.get_env("S3_ASSET_HOST") ||
|
||||
raise("Missing environment variable `S3_ASSET_HOST`!")
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
|
||||
@@ -304,6 +304,8 @@ defmodule Mixer.Accounts.User do
|
||||
filter expr(valid)
|
||||
end
|
||||
|
||||
has_many :tweet_likes, Mixer.Posts.TweetLike
|
||||
|
||||
has_many :tweets, Mixer.Posts.Tweet
|
||||
end
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ defmodule Mixer.Posts do
|
||||
|
||||
resources do
|
||||
resource Mixer.Posts.Tweet
|
||||
resource Mixer.Posts.TweetLike
|
||||
resource Mixer.Posts.Media
|
||||
end
|
||||
|
||||
typescript_rpc do
|
||||
resource Mixer.Posts.Tweet do
|
||||
rpc_action :create_tweet, :create
|
||||
rpc_action :like_tweet, :like
|
||||
rpc_action :read_tweet, :read
|
||||
rpc_action :unlike_tweet, :unlike
|
||||
rpc_action :update_tweet, :update
|
||||
rpc_action :destroy_tweet, :destroy
|
||||
end
|
||||
|
||||
@@ -11,6 +11,10 @@ defmodule Mixer.Posts.Media do
|
||||
postgres do
|
||||
table "media"
|
||||
repo Mixer.Repo
|
||||
|
||||
references do
|
||||
reference :tweet, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
typescript do
|
||||
|
||||
@@ -10,7 +10,7 @@ defmodule Mixer.Posts.MediaUploader do
|
||||
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
|
||||
end
|
||||
|
||||
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.id}"
|
||||
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
|
||||
|
||||
def filename(_version, {file, _scope}) do
|
||||
Path.basename(file.file_name, Path.extname(file.file_name))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
defmodule Mixer.Posts.Tweet do
|
||||
import Ash.Expr
|
||||
require Ash.Query
|
||||
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Posts,
|
||||
@@ -25,7 +28,7 @@ defmodule Mixer.Posts.Tweet do
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy, update: :*]
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
upsert? true
|
||||
@@ -50,6 +53,62 @@ defmodule Mixer.Posts.Tweet do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:content]
|
||||
end
|
||||
|
||||
update :like do
|
||||
accept []
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, context ->
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
case ensure_like(tweet, context.actor) do
|
||||
{:created, _like} ->
|
||||
increment_likes(tweet, context.actor)
|
||||
|
||||
{:noop, _like} ->
|
||||
{:ok, tweet}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
update :unlike do
|
||||
accept []
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, context ->
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
case remove_like(tweet, context.actor) do
|
||||
{:deleted, _like} ->
|
||||
decrement_likes(tweet, context.actor)
|
||||
|
||||
{:noop, _like} ->
|
||||
{:ok, tweet}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
update :increment_likes do
|
||||
accept []
|
||||
require_atomic? false
|
||||
change atomic_update(:likes, expr(likes + 1))
|
||||
end
|
||||
|
||||
update :decrement_likes do
|
||||
accept []
|
||||
require_atomic? false
|
||||
change atomic_update(:likes, expr(likes - 1))
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
@@ -70,6 +129,12 @@ defmodule Mixer.Posts.Tweet do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
create_timestamp :inserted_at do
|
||||
public? true
|
||||
end
|
||||
|
||||
update_timestamp :updated_at
|
||||
end
|
||||
|
||||
relationships do
|
||||
@@ -83,6 +148,21 @@ defmodule Mixer.Posts.Tweet do
|
||||
has_many :media, Mixer.Posts.Media do
|
||||
public? true
|
||||
end
|
||||
|
||||
has_many :tweet_likes, Mixer.Posts.TweetLike
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :user_email, :string, expr(user.email) do
|
||||
public? true
|
||||
end
|
||||
end
|
||||
|
||||
aggregates do
|
||||
exists :liked_by_me, :tweet_likes do
|
||||
public? true
|
||||
filter expr(user_id == ^actor(:id))
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
@@ -94,8 +174,93 @@ defmodule Mixer.Posts.Tweet do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action_type([:destroy, :update]) do
|
||||
policy action(:update) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action(:like) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:unlike) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
|
||||
|
||||
defp ensure_like(tweet, actor) do
|
||||
case get_like(tweet.id, actor.id) do
|
||||
{:ok, nil} ->
|
||||
case create_like(tweet.id, actor) do
|
||||
{:ok, like} ->
|
||||
{:created, like}
|
||||
|
||||
{:error, error} ->
|
||||
case get_like(tweet.id, actor.id) do
|
||||
{:ok, nil} ->
|
||||
{:error, error}
|
||||
|
||||
{:ok, like} ->
|
||||
{:noop, like}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, like} ->
|
||||
{:noop, like}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
|
||||
|
||||
defp remove_like(tweet, actor) do
|
||||
case get_like(tweet.id, actor.id) do
|
||||
{:ok, nil} ->
|
||||
{:noop, nil}
|
||||
|
||||
{:ok, like} ->
|
||||
case Ash.destroy(like, actor: actor) do
|
||||
:ok -> {:deleted, like}
|
||||
{:ok, _destroyed_like} -> {:deleted, like}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_like(tweet_id, actor) do
|
||||
Mixer.Posts.TweetLike
|
||||
|> Ash.Changeset.for_create(:create, %{tweet_id: tweet_id}, actor: actor)
|
||||
|> Ash.create()
|
||||
end
|
||||
|
||||
defp get_like(tweet_id, user_id) do
|
||||
Mixer.Posts.TweetLike
|
||||
|> Ash.Query.filter(expr(tweet_id == ^tweet_id and user_id == ^user_id))
|
||||
|> Ash.read_one(authorize?: false)
|
||||
end
|
||||
|
||||
defp increment_likes(tweet, actor) do
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:increment_likes, %{}, actor: actor)
|
||||
|> Ash.update(authorize?: false)
|
||||
end
|
||||
|
||||
defp decrement_likes(tweet, actor) do
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:decrement_likes, %{}, actor: actor)
|
||||
|> Ash.update(authorize?: false)
|
||||
end
|
||||
end
|
||||
|
||||
69
lib/mixer/posts/tweet_like.ex
Normal file
69
lib/mixer/posts/tweet_like.ex
Normal file
@@ -0,0 +1,69 @@
|
||||
defmodule Mixer.Posts.TweetLike do
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Posts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "tweet_likes"
|
||||
repo Mixer.Repo
|
||||
|
||||
references do
|
||||
reference :tweet, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
accept [:tweet_id]
|
||||
change relate_actor(:user)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :tweet_id, :uuid do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
attribute :user_id, :uuid do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :tweet, Mixer.Posts.Tweet do
|
||||
attribute_type :uuid
|
||||
attribute_writable? true
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
belongs_to :user, Mixer.Accounts.User do
|
||||
attribute_type :uuid
|
||||
attribute_writable? true
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_user_tweet, [:tweet_id, :user_id]
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:create) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,14 @@ defmodule MixerWeb.PageController do
|
||||
end
|
||||
|
||||
def index(conn, _params) do
|
||||
render_spa(conn, nil)
|
||||
end
|
||||
|
||||
def show(conn, %{"tweet_id" => tweet_id}) do
|
||||
render_spa(conn, tweet_id)
|
||||
end
|
||||
|
||||
defp render_spa(conn, tweet_id) do
|
||||
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
|
||||
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
|
||||
|
||||
@@ -13,7 +21,8 @@ defmodule MixerWeb.PageController do
|
||||
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|
||||
|> render(:index,
|
||||
current_user: conn.assigns[:current_user],
|
||||
media_host: "#{asset_host}/#{bucket}"
|
||||
media_host: "#{asset_host}/#{bucket}",
|
||||
tweet_id: tweet_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div id="app"
|
||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||
data-asset-host={@media_host}>
|
||||
data-asset-host={@media_host}
|
||||
data-tweet-id={@tweet_id || ""}>
|
||||
</div>
|
||||
|
||||
@@ -11,15 +11,17 @@ defmodule MixerWeb.UploadController do
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "authentication required"})
|
||||
else
|
||||
scope = %{id: Ash.UUID.generate()}
|
||||
media_id = Ash.UUID.generate()
|
||||
scope = %{user_id: actor.id, media_id: media_id}
|
||||
|
||||
case MediaUploader.store({upload, scope}) do
|
||||
{:ok, file_name} ->
|
||||
s3_key = "uploads/media/#{scope.id}/#{file_name}"
|
||||
s3_key = "uploads/media/#{scope.user_id}/#{scope.media_id}/#{file_name}"
|
||||
url = MediaUploader.url({file_name, scope})
|
||||
|
||||
Mixer.Posts.Media
|
||||
|> Ash.Changeset.for_create(:upload, %{s3_key: s3_key}, actor: actor)
|
||||
|> Ash.Changeset.force_change_attribute(:id, media_id)
|
||||
|> Ash.create()
|
||||
|> case do
|
||||
{:ok, media} ->
|
||||
|
||||
@@ -39,6 +39,7 @@ defmodule MixerWeb.Router do
|
||||
|
||||
get "/", PageController, :home
|
||||
get "/feed", PageController, :index
|
||||
get "/feed/:tweet_id", PageController, :show
|
||||
post "/rpc/run", AshTypescriptRpcController, :run
|
||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||
post "/upload", UploadController, :create
|
||||
|
||||
17
mix.exs
17
mix.exs
@@ -145,5 +145,22 @@ defmodule Mixer.MixProject do
|
||||
]
|
||||
]
|
||||
]
|
||||
[
|
||||
file: "AGENTS.md",
|
||||
usage_rules: ["usage_rules:all"],
|
||||
skills: [
|
||||
location: ".agents/skills",
|
||||
build: [
|
||||
"ash-framework": [
|
||||
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
|
||||
usage_rules: [:ash, ~r/^ash_/]
|
||||
],
|
||||
"phoenix-framework": [
|
||||
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
|
||||
usage_rules: [:phoenix, ~r/^phoenix_/]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
47
priv/repo/migrations/20260331182608_add_tweet_likes.exs
Normal file
47
priv/repo/migrations/20260331182608_add_tweet_likes.exs
Normal file
@@ -0,0 +1,47 @@
|
||||
defmodule Mixer.Repo.Migrations.AddTweetLikes do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:tweet_likes, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||
|
||||
add :tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "tweet_likes_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
|
||||
add :user_id,
|
||||
references(:users,
|
||||
column: :id,
|
||||
name: "tweet_likes_user_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
end
|
||||
|
||||
create unique_index(:tweet_likes, [:tweet_id, :user_id],
|
||||
name: "tweet_likes_unique_user_tweet_index"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:tweet_likes, [:tweet_id, :user_id],
|
||||
name: "tweet_likes_unique_user_tweet_index"
|
||||
)
|
||||
|
||||
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
|
||||
|
||||
drop constraint(:tweet_likes, "tweet_likes_user_id_fkey")
|
||||
|
||||
drop table(:tweet_likes)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
defmodule Mixer.Repo.Migrations.CascadeDeleteTweetRelations do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
|
||||
|
||||
alter table(:tweet_likes) do
|
||||
modify :tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "tweet_likes_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
end
|
||||
|
||||
drop constraint(:media, "media_tweet_id_fkey")
|
||||
|
||||
alter table(:media) do
|
||||
modify :tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "media_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:media, "media_tweet_id_fkey")
|
||||
|
||||
alter table(:media) do
|
||||
modify :tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "media_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
|
||||
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
|
||||
|
||||
alter table(:tweet_likes) do
|
||||
modify :tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "tweet_likes_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
defmodule Mixer.Repo.Migrations.AddTimestampsToTweets do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:tweets) do
|
||||
add :inserted_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
add :updated_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:tweets) do
|
||||
remove :updated_at
|
||||
remove :inserted_at
|
||||
end
|
||||
end
|
||||
end
|
||||
106
priv/resource_snapshots/repo/media/20260331210905.json
Normal file
106
priv/resource_snapshots/repo/media/20260331210905.json
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "s3_key",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "media_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "media_tweet_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "tweets"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "tweet_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "B9772141860212686745F81D509EBD97BACB9A5A4E7C26A0EB924D6926D1827E",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "media"
|
||||
}
|
||||
113
priv/resource_snapshots/repo/tweet_likes/20260331182609.json
Normal file
113
priv/resource_snapshots/repo/tweet_likes/20260331182609.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweet_likes_tweet_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "tweets"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "tweet_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweet_likes_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "8F9732A70AB924AD5FE6FBB20274DFBDB8EDCA638722B29BFA6D8E47C22BF7B6",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "tweet_likes_unique_user_tweet_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "tweet_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "user_id"
|
||||
}
|
||||
],
|
||||
"name": "unique_user_tweet",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "tweet_likes"
|
||||
}
|
||||
113
priv/resource_snapshots/repo/tweet_likes/20260331210906.json
Normal file
113
priv/resource_snapshots/repo/tweet_likes/20260331210906.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweet_likes_tweet_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "tweets"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "tweet_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweet_likes_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "A5479A2259477E7040C393810B5805794903152376377FA38E92C119C4947108",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "tweet_likes_unique_user_tweet_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "tweet_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "user_id"
|
||||
}
|
||||
],
|
||||
"name": "unique_user_tweet",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "tweet_likes"
|
||||
}
|
||||
123
priv/resource_snapshots/repo/tweets/20260401154313.json
Normal file
123
priv/resource_snapshots/repo/tweets/20260401154313.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "content",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "0",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "likes",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweets_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"drafted\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "state",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "5CA1873A0545807862B314C4E49F4E4538905E9BD3B40C33EE1AFE6ABD60538C",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "tweets"
|
||||
}
|
||||
117
test/mixer/posts/tweet_like_test.exs
Normal file
117
test/mixer/posts/tweet_like_test.exs
Normal file
@@ -0,0 +1,117 @@
|
||||
defmodule Mixer.Posts.TweetLikeTest do
|
||||
use Mixer.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
alias Mixer.Accounts.User
|
||||
alias Mixer.Posts.Tweet
|
||||
alias Mixer.Posts.TweetLike
|
||||
|
||||
describe "tweet likes" do
|
||||
test "a user can like a tweet once and liked_by_me reflects the actor" do
|
||||
user = user_fixture("liker@example.com")
|
||||
tweet = tweet_fixture(user, "first post")
|
||||
|
||||
assert {:ok, liked_tweet} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:like, %{}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert liked_tweet.likes == 1
|
||||
assert count_likes(tweet.id) == 1
|
||||
|
||||
tweet_for_actor =
|
||||
Tweet
|
||||
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
||||
|
||||
refute Ash.ForbiddenField.forbidden?(tweet_for_actor.liked_by_me)
|
||||
assert tweet_for_actor.liked_by_me
|
||||
|
||||
tweet_without_actor =
|
||||
Tweet
|
||||
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
||||
|
||||
refute Ash.ForbiddenField.forbidden?(tweet_without_actor.liked_by_me)
|
||||
refute tweet_without_actor.liked_by_me
|
||||
end
|
||||
|
||||
test "liking the same tweet twice does not create duplicate rows or inflate the counter" do
|
||||
user = user_fixture("duplicate@example.com")
|
||||
tweet = tweet_fixture(user, "duplicate like test")
|
||||
|
||||
assert {:ok, _tweet} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:like, %{}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert {:ok, liked_again} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:like, %{}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert liked_again.likes == 1
|
||||
assert count_likes(tweet.id) == 1
|
||||
end
|
||||
|
||||
test "unliking removes the relation and decrements the counter without going negative" do
|
||||
user = user_fixture("unlike@example.com")
|
||||
tweet = tweet_fixture(user, "unlike test")
|
||||
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:like, %{}, actor: user)
|
||||
|> Ash.update!()
|
||||
|
||||
assert {:ok, unliked_tweet} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:unlike, %{}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert unliked_tweet.likes == 0
|
||||
assert count_likes(tweet.id) == 0
|
||||
|
||||
assert {:ok, still_unliked} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:unlike, %{}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert still_unliked.likes == 0
|
||||
assert count_likes(tweet.id) == 0
|
||||
end
|
||||
|
||||
test "guests cannot like tweets" do
|
||||
owner = user_fixture("owner@example.com")
|
||||
tweet = tweet_fixture(owner, "guest like test")
|
||||
|
||||
assert {:error, error} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:like, %{})
|
||||
|> Ash.update()
|
||||
|
||||
assert Exception.message(error) =~ "forbidden"
|
||||
assert count_likes(tweet.id) == 0
|
||||
end
|
||||
end
|
||||
|
||||
defp user_fixture(email) do
|
||||
User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: email,
|
||||
password: "password1234",
|
||||
password_confirmation: "password1234"
|
||||
})
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
defp tweet_fixture(user, content) do
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: content}, actor: user)
|
||||
|> Ash.create!()
|
||||
end
|
||||
|
||||
defp count_likes(tweet_id) do
|
||||
TweetLike
|
||||
|> Ash.Query.filter(expr(tweet_id == ^tweet_id))
|
||||
|> Ash.read!(authorize?: false)
|
||||
|> length()
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user