Skip to content

Go-to-definition for Ash DSL-generated functions (code interfaces) resolves to line 1 #108

Description

@onnimonni

Summary

Go-to-definition on a function generated by the Ash DSL resolves only to line 1 of the module, not to the define line that generates it. dexter lookup --strict exits 1 for the same call.

Ash domains are common in Phoenix apps, and most calls into an Ash app go through these generated "code interface" functions. It would be great if Dexter could resolve them to the define line. Is there a way to support this, or a hook for DSL-generated functions?

Environment

  • Dexter 0.7.2 (nixpkgs), macOS 26.6 on arm64
  • Elixir 1.20.4, OTP 28
  • ash 3.33.9, spark 2.7.3 (the full app also uses ash_postgres 2.13.1, phoenix 1.8.14)

Minimal reproduction

A standalone project with the ETS data layer, so no database is needed.

mix.exs

defmodule Repro.MixProject do
  use Mix.Project

  def project do
    [app: :repro, version: "0.1.0", elixir: "~> 1.18", deps: [{:ash, "~> 3.33"}]]
  end
end

config/config.exs

import Config

config :ash, default_string_length_count: :codepoints

lib/repro/chat.ex

defmodule Repro.Chat do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource Repro.Chat.Room do
      define :list_rooms, action: :read
      define :get_room_by_slug, action: :read, get_by: [:slug]
      define :create_room, action: :create
    end
  end
end

lib/repro/chat/room.ex

defmodule Repro.Chat.Room do
  use Ash.Resource, domain: Repro.Chat, data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:read, create: [:name, :slug]]
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :slug, :string, allow_nil?: false, public?: true
  end

  identities do
    identity :slug, [:slug], pre_check_with: Repro.Chat
  end
end

lib/repro/caller.ex

defmodule Repro.Caller do
  alias Repro.Chat

  def run do
    {:ok, _room} = Chat.create_room(%{name: "Lounge", slug: "lounge"})
    Chat.get_room_by_slug!("lounge")
  end
end

Steps:

mix deps.get && mix compile
mix run -e 'IO.inspect(Repro.Caller.run().name)'   # prints "Lounge": the function exists
dexter init .
dexter lookup Repro.Chat get_room_by_slug!           # .../lib/repro/chat.ex:1
dexter lookup --strict Repro.Chat get_room_by_slug!  # no output, exit 1

Then ask the LSP for the definition of get_room_by_slug! in lib/repro/caller.ex, line 6, column 10.

Expected: lib/repro/chat.ex:7, the define :get_room_by_slug, ... line.

Actual: lib/repro/chat.ex:1. Debug log from dexter lsp with debug: true:

[debug] Definition: expr="Chat.get_room_by_slug!" module="Chat" function="get_room_by_slug!"
[debug] Definition: qualified call resolved "Chat" -> "Repro.Chat"
[debug] Definition: no result for Repro.Chat.get_room_by_slug!
=> [{"range":{"start":{"character":0,"line":0},"end":{"character":0,"line":0}},"uri":"file:///.../lib/repro/chat.ex"}]

What Ash generates, and where the line lives

For each define :name inside resources do resource X do ... end end in a module that does use Ash.Domain, Ash generates several public functions in the domain module. For the read in this repro, the compiled module has:

  • get_room_by_slug/1..3 and get_room_by_slug!/1..3
  • can_get_room_by_slug/2..4 and can_get_room_by_slug?/2..4
  • query_to_get_room_by_slug/1..3

Resources can declare the same thing in a code_interface do define ... end block, which generates the functions on the resource module instead. I only tested the domain form.

The compiled BEAM does not help: every generated clause has line: 1 in the debug info definitions, and the docs chunk anno is 1 too. Spark does keep the real location on the DSL entity, though. In the full app below, define :get_room_by_slug is on line 18 of the domain, and:

AgenticLiveview.ChatAsh
|> Ash.Domain.Info.resource_references()
|> Enum.flat_map(& &1.definitions)
|> Enum.find(&(&1.name == :get_room_by_slug))
|> Spark.Dsl.Entity.anno()
#=> [file: ~c".../lib/agentic_liveview/chat_ash.ex", location: 18]

Possible approaches

  1. Static: when a module does use Ash.Domain, index each define :name inside resources do ... resource X do ... end as the definition of name, name!, can_name, can_name? and query_to_name. Do the same for code_interface do define :name in use Ash.Resource modules. define_calculation :name generates name/.. too.
  2. Runtime, through the BEAM process Dexter already runs: when the store has no hit and the module is a Spark DSL module, ask Spark for the entity's anno.
  3. Generic: a small config or plugin hook that maps a DSL call such as define :name to generated function names, so Dexter need not know about Ash specifically.

I'm happy to help test whichever direction fits Dexter's design.

A related case that is not a Dexter bug

From Claude Code's LSP tool, go-to-definition into deps/ (for example Phoenix.PubSub.broadcast/3) returned nothing. Driving dexter lsp directly over stdio returns the right deps/phoenix_pubsub/lib/phoenix/pubsub.ex location, and workspace/symbol returns deps/ results too. The client is dropping locations in git-ignored folders, so I'm reporting that to Claude Code instead.

Full Ash setup from the real app

This is the chat-room part of a Phoenix app, where I first saw this. The call that fails is ChatAsh.get_room_by_slug!(scope: scope) in ChatBackend. It resolves to chat_ash.ex:1 instead of line 18. In the same line, ChatAsh.to_chat_room/1, a plain def, resolves correctly to both clauses. The resources use AshPostgres against existing tables, so they need a Postgres database to run, but Dexter only needs the source.

lib/agentic_liveview/chat_ash.ex (the domain; line 18 is the define)
defmodule AgenticLiveview.ChatAsh do
  @moduledoc """
  Ash spike (see `.claude/plans/ash-adoption`): rooms, members, and messages
  as Ash resources on the same tables as `AgenticLiveview.Chat`.

  Authorization lives in each resource's policies and takes the caller's
  `%Scope{}` through `scope:`. A read the caller may not make returns no rows;
  a write returns `{:error, %Ash.Error.Forbidden{}}`.
  """
  use Ash.Domain, otp_app: :agentic_liveview

  alias AgenticLiveview.Accounts.Scope

  resources do
    resource AgenticLiveview.ChatAsh.Room do
      define :list_rooms, action: :list
      define :first_room, action: :first, get?: true, not_found_error?: false
      define :get_room_by_slug, action: :read, get_by: [:slug], not_found_error?: false
      define :seed_room, action: :seed
    end

    resource AgenticLiveview.ChatAsh.RoomMember do
      define :list_members, action: :read
      define :invite_member, action: :invite, args: [:room_id, :user_id]
    end

    resource AgenticLiveview.ChatAsh.Message do
      define :list_room_messages, action: :in_room, args: [:room_id, {:optional, :before_id}]
      define :get_message, action: :read, get_by: [:id]
      define :post_message, action: :post, args: [:room_id]
      define :edit_message, action: :edit
      define :delete_message, action: :destroy
    end

    resource AgenticLiveview.ChatAsh.Reaction
    resource AgenticLiveview.ChatAsh.Attachment
  end

  @doc """
  The most recent page of a room's messages, oldest first, and whether older
  ones exist. A room the member may not see yields no messages.
  """
  def list_recent_messages(%Scope{} = scope, room_id),
    do: list_messages_before(scope, room_id, nil)

  @doc "A page of messages older than `before_id`, oldest first, and whether more exist."
  def list_messages_before(%Scope{} = scope, room_id, before_id) do
    page = list_room_messages!(room_id, before_id, scope: scope)
    {Enum.reverse(page.results), page.more?}
  end

  @doc "The latest message of every visible room, keyed by room id, with the author loaded."
  def latest_messages_by_room(%Scope{} = scope) do
    rooms = list_rooms!(scope: scope, load: [latest_message: [:user]])
    for %{latest_message: %{} = message} = room <- rooms, into: %{}, do: {room.id, message}
  end

  @doc """
  Spike glue: the Ecto `%Chat.Room{}` that every `Chat` function still
  pattern-matches on, built from a room the policies allowed. Goes away once
  those functions move to this domain.
  """
  def to_chat_room(nil), do: nil

  def to_chat_room(%AgenticLiveview.ChatAsh.Room{} = room) do
    fields = AgenticLiveview.Chat.Room.__schema__(:fields)
    struct(AgenticLiveview.Chat.Room, Map.take(room, fields))
  end

  @doc """
  Publishes an Ash notification on `AgenticLiveview.PubSub`.

  Not called from this codebase: `Ash.Notifier.PubSub` invokes it by name after
  each commit, with the payload the publication's `transform` built, so that
  subscribers of `Chat.subscribe/1` and `Chat.subscribe_all/0` receive the same
  tuples they would from the Ecto context.
  """
  def broadcast(topic, _event, payload) do
    Phoenix.PubSub.broadcast(AgenticLiveview.PubSub, topic, payload)
  end
end
lib/agentic_liveview/chat_backend.ex (the caller)
defmodule AgenticLiveview.ChatBackend do
  @moduledoc """
  The seam the Ash spike is measured through (`.claude/plans/ash-adoption`).

  `ChatLive` makes four reads the spike replaces: the room lookups behind
  `/chat` and `/chat/:slug`, and the two pages of message history. Every other
  call it makes still goes to `AgenticLiveview.Chat`, against the same tables.

  `config :agentic_liveview, :chat_backend` chooses the implementation - `:ecto`
  by default, `:ash` to route those four through `ChatAsh`'s policies instead.
  It is read per call rather than at compile time, so a test can flip it.

  Rooms are handed back as `%Chat.Room{}` either way, because the rest of
  `ChatLive` passes the room to `Chat` functions that pattern-match on it.
  Messages are not converted: under `:ash` the stream carries
  `%ChatAsh.Message{}`, which is the point - it is how the spike finds out
  whether the view and DenUI can live on plain loaded data.
  """

  alias AgenticLiveview.Accounts.Scope
  alias AgenticLiveview.Chat
  alias AgenticLiveview.ChatAsh

  @doc "The room behind `/chat/:slug`, or `nil` when it does not exist or is not visible."
  def get_room_by_slug(%Scope{} = scope, slug) do
    case backend() do
      :ecto -> Chat.get_room_by_slug(scope, slug)
      :ash -> slug |> ChatAsh.get_room_by_slug!(scope: scope) |> ChatAsh.to_chat_room()
    end
  end

  @doc "The room `/chat` redirects to, or `nil` when the caller can see none."
  def first_room(%Scope{} = scope) do
    case backend() do
      :ecto -> Chat.first_room(scope)
      :ash -> [scope: scope] |> ChatAsh.first_room!() |> ChatAsh.to_chat_room()
    end
  end

  @doc "The newest page of a room's messages, oldest first, and whether older ones exist."
  def list_recent_messages(%Scope{} = scope, room) do
    case backend() do
      :ecto -> Chat.list_recent_messages(scope, room)
      :ash -> ChatAsh.list_recent_messages(scope, room.id)
    end
  end

  @doc "A page older than `before_id`, oldest first, and whether more exist."
  def list_messages_before(%Scope{} = scope, room, before_id) do
    case backend() do
      :ecto -> Chat.list_messages_before(scope, room, before_id)
      :ash -> ChatAsh.list_messages_before(scope, room.id, before_id)
    end
  end

  @doc "Which implementation the four reads above are using right now."
  def backend, do: Application.get_env(:agentic_liveview, :chat_backend, :ecto)
end
lib/agentic_liveview/chat_ash/room.ex
defmodule AgenticLiveview.ChatAsh.Room do
  @moduledoc """
  A chat room. Private rooms are visible only to their members; the read
  policy below is the whole rule, so every read action inherits it.
  """
  use Ash.Resource,
    domain: AgenticLiveview.ChatAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "rooms"
    repo AgenticLiveview.Repo

    # The table is owned by priv/repo/migrations.
    migrate? false

    identity_index_names slug: "rooms_slug_index"
  end

  actions do
    # The primary read stays bare: Ash uses it for relationship loads and
    # policy checks. Listing sorts seeded rooms first, then member-created
    # ones in creation order.
    defaults [:read]

    read :list do
      prepare build(sort: [position: :asc, id: :asc])
    end

    read :first do
      prepare build(sort: [position: :asc, id: :asc], limit: 1)
    end

    # Seeds only (`priv/repo/seeds.exs` runs with `authorize?: false`).
    create :seed do
      accept [:name, :slug, :emoji, :description, :position, :private]
    end
  end

  policies do
    policy action_type(:read) do
      forbid_unless actor_present()
      authorize_if expr(not private)
      authorize_if expr(exists(room_members, user_id == ^actor(:id)))
    end

    policy action(:seed) do
      forbid_if always()
    end
  end

  attributes do
    integer_primary_key :id

    attribute :name, :string, allow_nil?: false, public?: true
    attribute :slug, :string, allow_nil?: false, public?: true
    attribute :emoji, :string, allow_nil?: false, public?: true

    attribute :description, :string,
      allow_nil?: false,
      default: "",
      public?: true,
      constraints: [allow_empty?: true, trim?: true]

    attribute :position, :integer, allow_nil?: false, default: 0, public?: true
    attribute :private, :boolean, allow_nil?: false, default: false, public?: true

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  relationships do
    belongs_to :creator, AgenticLiveview.AccountsAsh.User, attribute_type: :integer

    has_many :messages, AgenticLiveview.ChatAsh.Message
    has_many :room_members, AgenticLiveview.ChatAsh.RoomMember

    # The newest message, for the room list preview.
    has_one :latest_message, AgenticLiveview.ChatAsh.Message do
      from_many? true
      sort id: :desc
    end
  end

  aggregates do
    count :member_count, :room_members
    first :latest_message_id, :messages, :id, sort: [id: :desc]
  end

  identities do
    identity :slug, [:slug]
  end
end
lib/agentic_liveview/chat_ash/room_member.ex
defmodule AgenticLiveview.ChatAsh.RoomMember do
  @moduledoc """
  A member of a private room. Public rooms have no member rows: every
  signed-in member can read and write them. The creator of a room is its
  `owner`.
  """
  use Ash.Resource,
    domain: AgenticLiveview.ChatAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "room_members"
    repo AgenticLiveview.Repo

    # The table is owned by priv/repo/migrations.
    migrate? false

    identity_index_names membership: "room_members_room_id_user_id_index"
  end

  actions do
    defaults [:read]

    # Adds a member to a private room. Adding someone who already belongs is
    # a no-op that returns the existing row: the upsert touches only the
    # identity columns, so the role is never downgraded.
    create :invite do
      accept [:room_id, :user_id]
      upsert? true
      upsert_identity :membership
      upsert_fields []
    end
  end

  policies do
    # Member rows are visible to the members of that room.
    policy action_type(:read) do
      forbid_unless actor_present()
      authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
    end

    # Any current member may invite. The check runs against the inserted row
    # inside the transaction, so the new row itself is excluded: a member
    # cannot invite themself into a room they do not belong to.
    policy action(:invite) do
      forbid_unless actor_present()
      forbid_unless expr(room.private)
      authorize_if expr(exists(room.room_members, user_id == ^actor(:id) and id != parent(id)))
    end
  end

  attributes do
    integer_primary_key :id

    attribute :role, :string,
      allow_nil?: false,
      default: "member",
      constraints: [match: ~r/\A(owner|member)\z/]

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  relationships do
    belongs_to :room, AgenticLiveview.ChatAsh.Room, allow_nil?: false, attribute_type: :integer

    belongs_to :user, AgenticLiveview.AccountsAsh.User,
      allow_nil?: false,
      attribute_type: :integer
  end

  identities do
    identity :membership, [:room_id, :user_id]
  end
end
lib/agentic_liveview/chat_ash/message.ex
defmodule AgenticLiveview.ChatAsh.Message do
  @moduledoc "A message posted by a member in a room, with optional attachments and reactions."
  use Ash.Resource,
    domain: AgenticLiveview.ChatAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer],
    notifiers: [Ash.Notifier.PubSub]

  @page_size 50
  @max_length 1000

  postgres do
    table "messages"
    repo AgenticLiveview.Repo

    # The table is owned by priv/repo/migrations.
    migrate? false
  end

  actions do
    defaults [:read, :destroy]

    # A page of a room's messages, newest first. `more?` on the returned page
    # says whether older messages exist.
    read :in_room do
      argument :room_id, :integer, allow_nil?: false
      argument :before_id, :integer

      filter expr(
               room_id == ^arg(:room_id) and (is_nil(^arg(:before_id)) or id < ^arg(:before_id))
             )

      prepare build(sort: [id: :desc], load: [:user, :attachments, :reactions])

      pagination keyset?: true,
                 default_limit: @page_size,
                 max_page_size: @page_size,
                 required?: true
    end

    create :post do
      accept [:body]
      argument :room_id, :integer, allow_nil?: false

      change set_attribute(:room_id, arg(:room_id))
      # Not `relate_actor(:user)`: the actor is the Ecto `%Accounts.User{}`,
      # which cannot stand in for the Ash `User` record when `user` is loaded.
      change set_attribute(:user_id, actor(:id))
    end

    # Only the author may edit (see policies); the message is marked as edited.
    update :edit do
      accept [:body]
      change set_attribute(:edited_at, &DateTime.utc_now/0)
    end
  end

  policies do
    # Readable when the room is: public, or private with the actor as a member.
    policy action_type(:read) do
      forbid_unless actor_present()
      authorize_if expr(not room.private)
      authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
    end

    # Posting follows the same rule, checked against the inserted row inside
    # the transaction.
    policy action(:post) do
      forbid_unless actor_present()
      authorize_if expr(not room.private)
      authorize_if expr(exists(room.room_members, user_id == ^actor(:id)))
    end

    policy action([:edit, :destroy]) do
      authorize_if expr(user_id == ^actor(:id))
    end
  end

  pub_sub do
    module AgenticLiveview.ChatAsh

    # The room topic (`Chat.subscribe/1`) carries the record; the broad
    # "rooms" topic (`Chat.subscribe_all/0`) carries the room id so each
    # subscriber can check it against their own room list.
    publish :post, ["room", :room_id],
      load: [:user, :attachments, :reactions],
      transform: &{:message_created, &1.data}

    publish :post, "rooms",
      transform: &{:room_activity, &1.data.room_id, {:message_created, &1.data}}

    publish :edit, ["room", :room_id],
      load: [:user, :attachments, :reactions],
      transform: &{:message_updated, &1.data}

    publish :edit, "rooms",
      transform: &{:room_activity, &1.data.room_id, {:message_updated, &1.data}}

    publish :destroy, ["room", :room_id], transform: &{:message_deleted, &1.data}

    publish :destroy, "rooms",
      transform: &{:room_activity, &1.data.room_id, {:message_deleted, &1.data}}
  end

  attributes do
    integer_primary_key :id

    # Whitespace is trimmed and an empty body is rejected; attachment-only
    # messages are a Phase 1 concern.
    attribute :body, :string,
      allow_nil?: false,
      public?: true,
      constraints: [trim?: true, allow_empty?: false, max_length: @max_length]

    attribute :edited_at, :utc_datetime

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  relationships do
    belongs_to :room, AgenticLiveview.ChatAsh.Room, allow_nil?: false, attribute_type: :integer

    belongs_to :user, AgenticLiveview.AccountsAsh.User,
      allow_nil?: false,
      attribute_type: :integer

    has_many :attachments, AgenticLiveview.ChatAsh.Attachment
    has_many :reactions, AgenticLiveview.ChatAsh.Reaction
  end

  def page_size, do: @page_size
  def max_length, do: @max_length
end
lib/agentic_liveview/chat_ash/reaction.ex
defmodule AgenticLiveview.ChatAsh.Reaction do
  @moduledoc "An emoji reaction by a member on a message. Read-only in the spike."
  use Ash.Resource,
    domain: AgenticLiveview.ChatAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "message_reactions"
    repo AgenticLiveview.Repo

    # The table is owned by priv/repo/migrations.
    migrate? false

    identity_index_names one_per_member: "message_reactions_message_id_user_id_emoji_index"
  end

  actions do
    defaults [:read]
  end

  policies do
    # Visible with the message, so with the room.
    policy action_type(:read) do
      forbid_unless actor_present()
      authorize_if expr(not message.room.private)
      authorize_if expr(exists(message.room.room_members, user_id == ^actor(:id)))
    end
  end

  attributes do
    integer_primary_key :id

    attribute :emoji, :string, allow_nil?: false, public?: true

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  relationships do
    belongs_to :message, AgenticLiveview.ChatAsh.Message,
      allow_nil?: false,
      attribute_type: :integer

    belongs_to :user, AgenticLiveview.AccountsAsh.User,
      allow_nil?: false,
      attribute_type: :integer
  end

  identities do
    identity :one_per_member, [:message_id, :user_id, :emoji]
  end
end
lib/agentic_liveview/chat_ash/attachment.ex
defmodule AgenticLiveview.ChatAsh.Attachment do
  @moduledoc "A file attached to a message, stored under the uploads directory. Read-only in the spike."
  use Ash.Resource,
    domain: AgenticLiveview.ChatAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "message_attachments"
    repo AgenticLiveview.Repo

    # The table is owned by priv/repo/migrations.
    migrate? false
  end

  actions do
    defaults [:read]
  end

  policies do
    # Visible with the message, so with the room.
    policy action_type(:read) do
      forbid_unless actor_present()
      authorize_if expr(not message.room.private)
      authorize_if expr(exists(message.room.room_members, user_id == ^actor(:id)))
    end
  end

  attributes do
    integer_primary_key :id

    attribute :name, :string, allow_nil?: false, public?: true
    # File name inside the uploads directory.
    attribute :path, :string, allow_nil?: false, public?: true
    attribute :content_type, :string, allow_nil?: false, public?: true
    attribute :size, :integer, allow_nil?: false, public?: true

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  relationships do
    belongs_to :message, AgenticLiveview.ChatAsh.Message,
      allow_nil?: false,
      attribute_type: :integer
  end
end
lib/agentic_liveview/accounts_ash.ex
defmodule AgenticLiveview.AccountsAsh do
  @moduledoc """
  Ash spike (see `.claude/plans/ash-adoption`): a read-only view of members
  so chat resources can load message authors. Sign-in and settings stay in
  `AgenticLiveview.Accounts`.
  """
  use Ash.Domain, otp_app: :agentic_liveview

  resources do
    resource AgenticLiveview.AccountsAsh.User
  end
end
lib/agentic_liveview/accounts_ash/user.ex
defmodule AgenticLiveview.AccountsAsh.User do
  @moduledoc """
  A member, as chat resources see them: enough to render an author. Read-only
  in the spike; `AgenticLiveview.Accounts.User` still owns writes.
  """
  use Ash.Resource,
    domain: AgenticLiveview.AccountsAsh,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  postgres do
    table "users"
    repo AgenticLiveview.Repo

    # The users table is owned by priv/repo/migrations.
    migrate? false
  end

  actions do
    defaults [:read]
  end

  policies do
    # Mentions, member lists, and message authors: every signed-in member may
    # see every other member. Nothing is readable anonymously.
    policy action_type(:read) do
      authorize_if actor_present()
    end
  end

  attributes do
    integer_primary_key :id

    attribute :name, :string, allow_nil?: false, public?: true
    attribute :email, :string, sensitive?: true
    attribute :avatar_url, :string, public?: true
    attribute :provider_avatar_url, :string
    attribute :avatar_path, :string
    attribute :provider, :string, allow_nil?: false
    attribute :provider_uid, :string, allow_nil?: false
    attribute :email_on_mention, :boolean, allow_nil?: false, default: true
    attribute :time_format, :string, allow_nil?: false, default: "24h"

    create_timestamp :inserted_at, type: :utc_datetime
    update_timestamp :updated_at, type: :utc_datetime
  end

  identities do
    identity :provider_uid, [:provider, :provider_uid]
  end
end
lib/agentic_liveview/accounts/scope.ex (Ash.Scope.ToOpts impl)
defmodule AgenticLiveview.Accounts.Scope do
  @moduledoc """
  The caller's identity for context functions and LiveViews.

  See https://phoenix.hexdocs.pm/scopes.html. A scope with a `nil` user is an
  anonymous visitor.
  """

  alias AgenticLiveview.Accounts.User

  defstruct user: nil

  def for_user(%User{} = user), do: %__MODULE__{user: user}
  def for_user(nil), do: nil
end

# Lets Ash actions take `scope: scope` and read the actor from it. The app has
# no tenants, tracer, or per-call context, so those stay at Ash's defaults.
defimpl Ash.Scope.ToOpts, for: AgenticLiveview.Accounts.Scope do
  def get_actor(%{user: user}), do: {:ok, user}
  def get_tenant(_scope), do: :error
  def get_context(_scope), do: :error
  def get_tracer(_scope), do: :error
  def get_authorize?(_scope), do: :error
end
Ash config (config/config.exs)
config :agentic_liveview,
  ecto_repos: [AgenticLiveview.Repo],
  generators: [timestamp_type: :utc_datetime],
  ash_domains: [AgenticLiveview.AccountsAsh, AgenticLiveview.ChatAsh],
  # Ash spike (see .claude/plans/ash-adoption): `:ash` routes the chat room
  # lookup and message history through the ChatAsh policies; `:ecto` is the
  # Chat context. Both read the same tables.
  chat_backend: :ecto

config :ash,
  allow_forbidden_field_for_relationships_by_default: true,
  include_embedded_source_by_default?: false,
  show_keysets_for_all_actions?: false,
  default_page_type: :keyset,
  policies: [no_filter_static_forbidden_reads?: false],
  keep_read_action_loads_when_loading?: false,
  default_actions_require_atomic?: true,
  read_action_after_action_hooks_in_order?: true,
  bulk_actions_default_to_errors?: true,
  transaction_rollback_on_error?: true,
  redact_sensitive_values_in_errors?: true,
  default_string_length_count: :codepoints,
  many_to_many_destroy_destination_on_match?: true,
  known_types: [AshPostgres.Timestamptz, AshPostgres.TimestamptzUsec]

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions