Getting started with AshLua

Copy Markdown View Source

AshLua lets you expose your Ash domains and resources to Lua scripts, with a consistent actor / tenant / context attached to every call. Scripts can read data, create / update / delete records, and call generic actions — all using the same authorization, validation, and types your application already uses elsewhere.

This guide walks you through installing AshLua, exposing your first resource, evaluating a Lua script, and using the documentation surface so you (or an LLM-driven tool) can discover what's callable.

1. Install

Add the dependency:

def deps do
  [
    {:ash_lua, "~> 0.1.0"}
  ]
end

Run the installer to wire the formatter and any extension defaults:

mix igniter.install ash_lua

2. Expose a domain and a resource

AshLua ships two Spark extensions: AshLua.Domain (for your domain modules) and AshLua.Resource (for each resource you want to expose). Add them to their respective extensions: lists.

defmodule MyApp.Accounts do
  use Ash.Domain,
    otp_app: :my_app,
    extensions: [AshLua.Domain]

  resources do
    resource MyApp.Accounts.User
  end
end

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    domain: MyApp.Accounts,
    extensions: [AshLua.Resource]

  # ... attributes / actions ...
end

By default, the domain is exposed under a Lua table named after the domain module's last segment (snake_cased), and each resource under a similarly-derived key. You can override either name:

defmodule MyApp.Accounts do
  use Ash.Domain, otp_app: :my_app, extensions: [AshLua.Domain]

  lua do
    name "accounts"
  end

  # ...
end

defmodule MyApp.Accounts.User do
  use Ash.Resource, domain: MyApp.Accounts, extensions: [AshLua.Resource]

  lua do
    name "user"
  end

  # ...
end

With these defaults, every public action on MyApp.Accounts.User becomes callable as accounts.user.<action>(input) from Lua.

3. Evaluate a Lua script

{[user_id], _lua} =
  AshLua.eval!(
    """
    local user, err = accounts.user.create({
      name = "Zach",
      email = "z@example.com",
      fields = { "id" }
    })
    assert(err == nil)
    return user.id
    """,
    otp_app: :my_app,
    actor: current_user
  )

AshLua.eval!/2 accepts:

  • :otp_app — required; the OTP app whose Ash domains to expose.
  • :actor, :tenant, :context — host-supplied; threaded through every Ash call the script makes. These are not readable from Lua and cannot be overridden from the script — the host is the sole source of authority.
  • :manifest / :lua — optional pre-built artifacts (for advanced use).

4. The Lua API shape

Every operation in Lua is called as a function on a domain table and returns two values: a result and an error. A successful call returns (result, nil); a failed call returns (nil, err_table).

local user, err = accounts.user.create({ name = "Zach" })

if err then
  -- err is a table: { message = "...", errors = { { code = "...", fields = {...}, ... }, ... } }
  print("create failed:", err.message)
else
  print("created user:", user.id)
end

If you'd rather have errors raise, wrap the call in Lua's built-in assert:

local user = assert(accounts.user.create({ name = "Zach" }))

assert returns the first value when the second is nil, and raises with the second value otherwise — so it works exactly like an Elixir ! variant for free.

5. Choosing which fields come back

By default, operations that return records return only the primary key. Pass a fields selection to opt into more:

local user = assert(accounts.user.read({
  fields = { "id", "name", "email" }
}))

fields accepts a tree:

fields = {
  "id", "title",                                       -- stored fields
  "title_upper",                                       -- computed fields
  "comment_count",                                     -- summary fields
  { author = { "id", "name" } },                       -- linked one-record
  { comments = { "id", "body" } },                     -- linked many-records
  { title_prefixed = { args = { prefix = ">> " } } },  -- computed with input
  { metadata = { "priority", "category" } },           -- structured-value sub-selection
  { coordinates = { "latitude" } },                    -- tuple sub-selection
  { content = { text = { "body" } } },                 -- one-of (union) member sub-selection
}

Passing { author = {} } (an empty sub-selection) means "use the default for the linked record" — primary key only. Passing an explicit list selects exactly what you ask for and nothing else.

Unknown fields, unknown computed-field arguments, and other selection mistakes surface as a structured (nil, err) with code = "invalid_fields".

6. Querying lists

List-style read operations accept a few reserved keys:

local results = assert(posts.post.read({
  filter  = { published = true },                  -- narrow the result set
  sort    = "-created_at",                         -- `-` prefix for descending
  limit   = 10,
  offset  = 20,
  fields  = { "title", { author = { "name" } } },
}))

To paginate with a cursor instead, use page = { limit = 10, after = "..." }. The result becomes a table with results, count, limit, more?, and offset or before / after depending on the pagination style.

To summarize a result set without retrieving the records, use operation:

local total      = assert(posts.post.read({ operation = "count" }))
local any        = assert(posts.post.read({ operation = "exists" }))
local avg_rating = assert(posts.comment.read({ operation = { "avg", "rating" } }))
local high_sum   = assert(posts.comment.read({
  filter    = { rating = { greater_than_or_equal = 5 } },
  operation = { "sum", "rating" }
}))

Supported operations: "count", "exists", and { "sum" | "avg" | "min" | "max" | "count", "<field>" }.

7. Mutations

Create / update / delete behave like read, except update and delete take the primary key inline in the input:

local post = assert(posts.post.create({
  title = "Hello", body = "World", fields = { "id", "title" }
}))

local updated = assert(posts.post.update({
  id = post.id, title = "Hello again", fields = { "title" }
}))

assert(posts.post.destroy({ id = post.id }))

Generic actions (defined with action :name, type do ... end) take their declared arguments and return whatever the action returns — a scalar, a map, a list, whatever. fields is honored when the return type is a record or a structured value.

8. Discovering the API surface

AshLua.Docs produces per-operation and per-record-type markdown straight from your domains — useful both as a reading aid and as a feed for an MCP search_docs / get_docs tool (e.g. ash_ai).

AshLua.Docs.list_callables(otp_app: :my_app)
# => ["accounts.user.create", "accounts.user.read", ...]

{:ok, md} = AshLua.Docs.callable_doc([otp_app: :my_app], "accounts.user.create")

AshLua.Docs.list_types(otp_app: :my_app)
# => ["accounts.user", ...]

{:ok, md} = AshLua.Docs.type_doc([otp_app: :my_app], "accounts.user")

# Or get one rendered page covering everything:
AshLua.Docs.full_doc(otp_app: :my_app)

The rendered pages deliberately avoid implementation vocabulary — they describe operations as get / list / create / update / delete / call, and treat stored, computed, and summary fields uniformly as "fields".

9. Authorization and multitenancy

The actor, tenant, and context you pass to AshLua.eval!/2 are merged into every Ash call the script makes. That means:

  • Authorization policies fire as they normally would for that actor.
  • Tenant-scoped resources are scoped to the tenant you supplied.
  • Custom context (audit metadata, request IDs, etc.) reaches your changes, preparations, and policies.

There is intentionally no Lua-side API to read or modify any of these — a script cannot escalate its actor, leak its actor's identity, or switch tenants.

Next steps

  • Run AshLua.Docs.full_doc(otp_app: :my_app) to see what's automatically exposed.
  • Look at the per-callable pages for operations you want to script against.
  • Wire AshLua.eval!/2 into wherever scripts come from in your application (admin UI, scheduled jobs, MCP tools, etc.).