8000 fix(deps): upgrade ash for related-filter authorization by mwoods79 · Pull Request #680 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

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
1 change: 1 addition & 0 deletions .agents/skills/ash-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ metadata:
- [query_filter](references/ash/query_filter.md)
- [querying_data](references/ash/querying_data.md)
- [relationships](references/ash/relationships.md)
- [temporal](references/ash/temporal.md)
- [testing](references/ash/testing.md)

### ash_ai
Expand Down
92 changes: 92 additions & 0 deletions .agents/skills/ash-framework/references/ash/temporal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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, * 8000 *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)
```
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"absinthe": {:hex, :absinthe, "1.12.0", "fe69ee9940e47a6a80ab506f2c1def96964ab52e7f52fe2cd642d0f574967931", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b0672939fa75c24b6286c784edc01bec1bedfbe58885acdda5d097c7053b03c6"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.5", "1fdcfc59db241b1d5e45e4c8c9db162277e64ec5728e0fd6958a3fa491a05b82", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "086c6d4a1c32f7444713130d204c87b1b006169f5159026b73f02f7d38ccd05c"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.10", "c9e207235aaa8a086a5db6801a9bebaea035f7b5a2703cb98d962646ef70c76f", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "489ac1951c8e4128571141c60a0669a720619bc161f801a8c6be8cfaf7ab0979"},
"ash": {:hex, :ash, "3.34.4", "b799d852dca874b4afbe3bfa3477099e23b58830250fa6144c48ef7f58cf67d0", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.7.6 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bb82f7e412fce03b50abbe87a260ce956d1f67bc97d0544f011fa0d1882f07e4"},
"ash": {:hex, :ash, "3.34.6", "70ce402edbe6930570ce930661859b3be514dfd34700f8432c565e526ba9a990", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.7.6 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1109afed2bcf1f405aa035013d016a1ca88dab485ba2fb0c0885e7e7e9c6ae0f"},
"ash_ai": {:hex, :ash_ai, "1.1.1", "f8e10dec47a9177f62488f64b611ae29eccb7cdc1e073525a5b1a00c074030a6", [:mix], [{:ash, ">= 3.33.6 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.8 or ~> 5.0-rc", [hex: :ash_authentication, repo: "hexpm", optional: true]}, {:ash_json_api, ">= 1.4.27 and < 2.0.0-0", [hex: :ash_json_api, repo: "hexpm", optional: false]}, {:ash_oban, "~> 0.5", [hex: :ash_oban, repo: "hexpm", optional: true]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: true]}, {:ash_postgres, "~> 2.5", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:open_api_spex, "~> 3.0", [hex: :open_api_spex, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: true]}, {:req_llm, "~> 1.24", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "a6faf5187e6828f02a2f3a65cbfd645fbf3e56f6f16e8bc66e943c119939d25f"},
"ash_archival": {:hex, :ash_archival, "2.0.3", "40590162ece428c097e98da8c5b8c7cb073b6af72fd1acf7ba5b36f3d731df4d", [:mix], [{:ash, ">= 3.0.5 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "33f3f3006aef7e4271573c85db4e9422b5e1b0d7cfb110f52732d5004cafa62a"},
"ash_authentication": {:hex, :ash_authentication, "4.15.0", "e68d30e8bfc3c73bccd2e1bbf0dd15913a27951334dc5558e634b6f46a788ee9", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "3919317e97465d369292a7aa430836efe42b636d5467ab7196200bd25df6c2d7"},
Expand Down
2 changes: 1 addition & 1 deletion test/huddlz/communities/huddl/recurrence_helper_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ defmodule Huddlz.Communities.Huddl.RecurrenceHelperTest do
expected_second = Date.add(source_date, 14)
expected_third = Date.add(source_date, 21)

assert dates == Enum.sort([expected_first, expected_second, expected_third])
assert dates == Enum.sort([expected_first, expected_second, expected_third], Date)
end

test "generates no huddlz when repeat_until is before next occurrence", ctx do
Expand Down
Loading
0