Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ jobs:
-
uses: erlef/setup-beam@v1
with:
otp-version: "29.0.3"
elixir-version: "1.20.3"
version-file: mise.toml
version-type: strict
# Without this, setup-beam's problem matchers promote every
# compiler warning from deps (e.g. postgrex/rewrite's deprecated
# `xref: [exclude: ...]`, yamerl's deprecated `catch ...` syntax -
Expand Down Expand Up @@ -168,8 +168,8 @@ jobs:
-
uses: erlef/setup-beam@v1
with:
otp-version: "29.0.3"
elixir-version: "1.20.3"
version-file: mise.toml
version-type: strict
disable_problem_matchers: true
-
uses: mlugg/setup-zig@v2.2.1
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ jobs:
-
uses: erlef/setup-beam@v1
with:
otp-version: "29.0.3"
elixir-version: "1.20.3"
version-file: mise.toml
version-type: strict
# Without this, setup-beam's problem matchers promote every
# compiler warning from deps (e.g. postgrex/rewrite's deprecated
# `xref: [exclude: ...]`, yamerl's deprecated `catch ...` syntax -
Expand Down Expand Up @@ -242,8 +242,8 @@ jobs:
# setup-zig/p7zip here.
uses: erlef/setup-beam@v1
with:
otp-version: "29.0.3"
elixir-version: "1.20.3"
version-file: mise.toml
version-type: strict
disable_problem_matchers: true
-
run: mix deps.get
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/usage-rules-sync.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ jobs:
-
uses: erlef/setup-beam@v1
with:
otp-version: "29.0.3"
elixir-version: "1.20.3"
version-file: mise.toml
version-type: strict
disable_problem_matchers: true
-
run: mix deps.get
Expand Down
205 changes: 205 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ Ash is an opinionated, composable framework for building applications in Elixir.
inside the same transaction.
- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic
outside the transaction.
- When other resources should react to an action, and your action doesn't depend on the return value, emit a signal with `emit` in the action (with `dynamic?: true` when you emit it yourself with `Ash.Signals.emit/4`) and listen to it in those resources, instead of calling them from a hook. See the signals rules.
- Use action arguments for inputs that need validation
- Use preparations to modify queries before execution
- Preparations support `where` clauses for conditional execution
Expand Down Expand Up @@ -1533,6 +1534,209 @@ MyDomain.update_post!(post, %{


<!-- ash:relationships-end -->
<!-- ash:signals-start -->
## ash:signals usage
# Signals

Signals let one resource react to what happens in another without either depending on the other. A signal module declares typed signals, resources emit them from their actions, and other resources listen to them with their own actions.

## When to Use Signals

- Use a signal when other resources should react to an action, and the resource taking the action shouldn't know about them. For example, awarding loyalty points when an order is placed. Prefer this to calling those resources from an `after_action` hook.
- Don't use a signal when the action needs something back from the other resource to finish. Call that resource directly, for example through its code interface. Signals are fire and forget: the emitter gets nothing back from its listeners.
- Use a notifier, not a signal, for "at most once" side effects after the transaction commits, like publishing to PubSub.
- With the default delivery, listeners of `:before_action` and `:after_action` signals run as nested actions inside the emitter's transaction. A failing listener fails the emitting action and rolls it back.

## Declaring Signals

```elixir
defmodule MyApp.Shop.Signals do
use Ash.Signals

signals do
signal :order_placed do
argument :order_id, :uuid, allow_nil?: false
argument :customer_id, :uuid, allow_nil?: false
end
end
end
```

- Declare signals in a signal module, not in a resource. Emitters and listeners both depend on the signal module, and never on each other.
- Each signal defines a struct, like `MyApp.Shop.Signals.OrderPlaced`, which is what listeners receive.
- Every signal has a `signal_id` field, a UUIDv7 generated when it is emitted. Don't declare an argument named `signal_id`.
- A signal's `phase` is when it is emitted: `:before_action`, `:after_action` or `:after_transaction`. It defaults to `:after_action`, which you should prefer. `:before_action` and `:after_transaction` signals make update and destroy actions non-atomic, and in bulk actions, `:after_transaction` signals are emitted once per record instead of once per batch.

## Emitting Signals

```elixir
create :place do
accept [:customer_id, :total]

emit MyApp.Shop.Signals, :order_placed, fields: [order_id: :id]
end
```

- Emit signals with `emit` in create, update, destroy and generic actions. An action can only emit the signals it declares with `emit`.
- For a signal emitted in another phase than `:after_action`, pass the phase, as in `emit MyApp.Shop.Signals, :order_shipped, phase: :after_transaction`. It must match the phase the signal declares.
- Signal fields are taken from the record's attributes of the same name. In generic actions, they are taken from the action's arguments, and `fields` takes them from the action's result.
- Use `fields: [signal_field: :attribute]` to take signal fields from other attributes, and `values: [signal_field: value]` to set them to values or templates like `actor(:id)`, `arg(:reason)` or `previous(:status)`.
- Use `where` to emit only in some cases: `emit MyApp.Shop.Signals, :order_status_changed, where: [changing(:status)]`.
- `emit` runs in its place among the action's changes, and is checked at compile time.
- An update or destroy that emits a `:before_action` or `:after_transaction` signal, or uses `previous/1`, can't be atomic and needs `require_atomic? false`. `:after_action` signals don't need it.
- `fields` only reads attributes. When the signal needs a calculation, an aggregate, related data or any other transformation, emit it from your own code: declare it with `emit MyApp.Shop.Signals, :order_placed, dynamic?: true`, and call `Ash.Signals.emit/5` from a change during the signal's phase, or `Ash.Signals.emit_many/5` from `after_batch/3`. Emitting a signal the action doesn't declare raises an error.

## Listening to Signals

```elixir
signals_in do
from MyApp.Shop.Signals do
on :order_placed, :award_points
on :order_placed, :open_task, action: :create, inputs: [reference: :order_id]
on :order_placed, :mark_ordered, read_action: :active, get_by: [id: :customer_id]
end
end
```

- Each `on` has a name, unique within the resource, and runs the action with that name unless `action` is set.
- A **generic action** receives the signal struct as its `signal` argument. With `batch?: true`, it is called once with every signal emitted together, as its `signals` argument.

```elixir
action :award_points do
argument :signal, MyApp.Shop.Signals.OrderPlaced, allow_nil?: false
run MyApp.Loyalty.AwardPoints
end
```

- A **create action** creates a record for each signal. Its inputs are taken from the signal fields of the same name, except `signal_id`. Map others with `inputs: [input: :signal_field]`.
- An **update or destroy action** runs on the records selected for each signal by a read action, the primary read action unless `read_action` is set. Select them with `get_by: [field: :signal_field]`, `args: [read_argument: :signal_field]`, or both. If no records are selected, nothing happens. To react to only some signals, filter in the read action.
- Create, update and destroy listeners handle the signals emitted together as bulk actions.
- A listener's `delivery` decides how signals reach it. The default, `Ash.Signals.Delivery.Inline`, runs it immediately, during the signal's phase. Other deliveries come from extensions and implement `Ash.Signals.Delivery`, for example to run the listener in a job: `on :order_placed, :send_receipt, delivery: {SomeJobDelivery, queue: :mailers}`. Each listener chooses its own delivery, and the emitter doesn't change.
- Run a listener in a job when it does slow work or calls external services. Keep it inline when its changes must be committed with the emitter's, or when its failure should fail the emitting action.
- Listeners run with the emitting action's actor, tenant and `authorize?`. The listener resource's policies are checked against that actor, so write them with the emitting actor in mind.
- A listener's return value is ignored. Don't design listeners to return something the emitter needs.

## Setup

- Add the `:ash_signals` compiler to the project: `compilers: [:ash_signals] ++ Mix.compilers()`. Without it, listeners are searched for every time a signal is emitted, which is much slower.
- With the Phoenix code reloader, add `:ash_signals` to the endpoint's `reloadable_compilers` too.
- Listeners defined after compilation, like resources defined inside tests, aren't found when signals are consolidated. If tests define listeners, set `ash: [consolidate_signals: Mix.env() != :test]` in the project config.

## Testing

- Use `Ash.Signals.Test`. `assert_emits_signal/4` and `refute_emits_signal/4` run a function, check the signals it emitted, and return its result.
- `assert_signal_emitted/4` and `refute_signal_emitted/4` check the signals emitted so far, after `setup :capture_signals`.

```elixir
import Ash.Signals.Test

test "placing an order emits order_placed" do
order =
assert_emits_signal MyApp.Shop.Signals, :order_placed, %{order_id: order_id}, fn ->
MyApp.Shop.place_order!(customer, 100)
end

assert order.id == order_id
end
```

<!-- ash:signals-end -->
<!-- ash:temporal-start -->
## ash:temporal usage
# Temporal Resources

Temporal resources are experimental. A temporal resource keeps every version of a record, and each version is valid for a half-open period `[from, to)`. Every read and write happens "as of" one instant. Read the "Temporal Resources" guide before working with them.

## Supported Data Layers

- Only `AshPostgres.DataLayer` on PostgreSQL 18+ and `Ash.DataLayer.Ets` support temporal resources. On any other data layer, a resource that declares `temporal` won't compile.
- Manual actions skip temporal handling entirely. If you write one, you manage periods yourself.

## Defining a Temporal Resource

```elixir
temporal do
strategy :context
attribute :valid_at
# optional: wall-clock time each version was written
recorded_at :recorded_at
end
```

- You don't have to declare the period attribute. If you leave it out, `temporal` adds an `Ash.Type.Range` over `:utc_datetime_usec` with `[from, to)` bounds.
- If you do declare it, it must be an `Ash.Type.Range` over `:utc_datetime_usec`, `:utc_datetime` or `:datetime`, with `allow_nil? false`, an inclusive lower bound and an exclusive upper bound. Ranges over dates, naive datetimes or other types are refused at compile time.
- **Never put the period attribute in an action's `accept`**, and never set it in a change. Its value always comes from the write's `as_of`.
- Use `mix ash.codegen` for migrations. It generates the `PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)` and installs `btree_gist`. Don't write these by hand.

## Reading

- To read at a point in time, use `Ash.Query.as_of/2` or pass `as_of:` anywhere `tenant:` is accepted (`Ash.read`, `Ash.get`, code interfaces, `for_read`, and so on).
- If you leave out `as_of`, the read happens now and returns exactly one row per record. It never returns the full history.
- Every read is a single instant. A range-valued `as_of` on a read raises `Ash.Error.Query.AsOfNotAnInstant`. Don't try to fetch several versions of a record in one query.
- `as_of` propagates like `tenant`. Loaded relationships, calculations, aggregates and nested actions all run at the same instant, so you don't need to pass it to them again.
- In expressions, `now()`, `ago/2` and `from_now/2` mean the query's `as_of`, not the wall clock. Write `expr(expires_at > now())`, not `^DateTime.utc_now()`. Pinning the wall clock breaks time travel.

```elixir
Ash.get!(MyApp.Subscription, 1, as_of: ~U[2026-01-15 00:00:00Z])

MyApp.Subscription
|> Ash.Query.filter(plan == "gold")
|> Ash.Query.as_of(~U[2026-01-15 00:00:00Z])
|> Ash.read!()
```

## Writing

- A write makes something true from `as_of` onward. If you don't pass `as_of`, it uses now. One instant is pinned for the whole write, and `&DateTime.utc_now/0` defaults and `set_attribute(..., &DateTime.utc_now/0)` resolve to that same instant. Back-dated writes therefore get back-dated timestamps. Use `recorded_at` if you need the actual time a version was written.
- **Pass `as_of` as an action option** (`for_create(:create, input, as_of: ...)`, `for_update(...)`, `for_destroy(...)`). `Ash.Changeset.as_of/2` is applied too late for cascading destroys, `manage_relationship`, and identity pre-checks.
- A **create** opens `[as_of, ∞)`. You can't create a record if any later version of it already exists.
- An **update** splits the version valid at `as_of`. The new values run from `as_of` to wherever that version ended. To schedule a future change, write an update with a future `as_of`, not a create.
- A **destroy** closes the version valid at `as_of` down to `[from, as_of)`. It doesn't delete history.
- An update or destroy with no version valid at `as_of` (because the history ended, the record hasn't started yet, or `as_of` falls in a gap) fails as a stale record. To bring back a record whose history has ended, **create** it again rather than updating it.
- On Postgres, concurrent writes to the same record are retried automatically, up to 25 conflicts, after which `AshPostgres.Temporal.WriteConflict` is raised. It's safe to retry. Under `REPEATABLE READ`/`SERIALIZABLE` you get a serialization failure instead, and retrying is up to you.

## Changes, Validations and Preparations

- Any change, validation or preparation that runs on a temporal resource must declare `temporal_safe?/1` returning `true`. Otherwise the action raises `Ash.Error.Framework.NotTemporalSafe`. The callback defaults to `false`.
- Only declare a module temporal safe if it never reads the wall clock (use `now()` in expressions or the subject's `as_of` instead), has no side effects that assume the present, and does all of its reads and nested actions through Ash so `as_of` propagates.
- The built-in changes, validations and preparations are temporal safe. These aren't, because they wrap arbitrary functions: `before_action`, `after_action`, `before_transaction`, `after_transaction`, and anonymous-function changes, validations and preparations. Move that logic into a module that declares `temporal_safe?/1`.
- For third-party modules that you've verified are safe but that don't declare the callback, add them to `config :ash, :temporal_safe_modules, [...]`. The list is read at compile time.

```elixir
defmodule MyApp.Changes.Slugify do
use Ash.Resource.Change

@impl true
def temporal_safe?(_opts), do: true

@impl true
def change(changeset, _opts, _context) do
# ...
end
end
```

## Relationships and Identities

- To make a `belongs_to` point at another temporal resource for the matching period, declare `temporal_keys {:valid_at, :valid_at}`. On Postgres this produces a `PERIOD` foreign key.
- `PERIOD` foreign keys only support `NO ACTION`, so `on_delete`/`on_update` referential actions are rejected at compile time. Cascade in the application instead, for example with `change cascade_destroy(:subscriptions)`.
- Write identities as usual (`identity :unique_email, [:email]`). On a temporal resource they become "unique at every instant" (`UNIQUE (... WITHOUT OVERLAPS)`). Don't add the period attribute to an identity.

## Authorization

- Data and filter checks are evaluated as of the query's `as_of`. Actor attribute checks (`actor_attribute_equals`, `^actor(:field)`) use the actor struct exactly as you loaded it. Ash does not re-fetch the actor at the query's instant.
- **Load the actor as of the same instant you query.** Otherwise you're authorizing historical data with the actor's current attributes.

```elixir
as_of = ~U[2026-01-15 00:00:00Z]
actor = Ash.get!(MyApp.User, user_id, as_of: as_of)

MyApp.Subscription
|> Ash.Query.as_of(as_of)
|> Ash.read!(actor: actor)
```

<!-- ash:temporal-end -->
<!-- ash:testing-start -->
## ash:testing usage
# Testing
Expand All @@ -1543,6 +1747,7 @@ When testing resources:
- 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`
- Assert on emitted signals with `Ash.Signals.Test`
- Prefer to use raising versions of functions whenever possible, as opposed to pattern matching

## Preventing Deadlocks in Concurrent Tests
Expand Down
3 changes: 3 additions & 0 deletions app/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ defmodule LinearCli.MixProject do
{:ash, "~> 3.0"},
{:igniter, "~> 0.6", only: [:dev, :test]},
{:sbom, "~> 0.10", only: :dev, runtime: false},
# sbom pins hex_core ~> 0.19.0, which carries EEF-CVE-2026-103502 and
# EEF-CVE-2026-105274; override until sbom allows 0.20.
{:hex_core, "~> 0.20.0", only: :dev, runtime: false, override: true},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}
# {:dep_from_hexpm, "~> 0.3.0"},
Expand Down
Loading
Loading